From e83925ba64bb19b578f08455d6c9385461d7a390 Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Sat, 11 Apr 2026 01:23:28 +0200 Subject: [PATCH] Nexus upgrade design and refresh --- modules/mining-checker/api/index.php | 6 + modules/mining-checker/assets/css/.gitkeep | 0 modules/mining-checker/assets/css/app.css | 961 ++++++ modules/mining-checker/assets/js/.gitkeep | 0 modules/mining-checker/assets/js/app.js | 2983 +++++++++++++++++ modules/mining-checker/bootstrap.php | 16 + .../mining-checker/config/example.config.php | 24 + modules/mining-checker/config/module.php | 46 + modules/mining-checker/docs/README.md | 125 + modules/mining-checker/module.json | 10 + modules/mining-checker/pages/index.php | 32 + modules/mining-checker/partials/.gitkeep | 0 .../sql/migrations/001_init.sql | 123 + .../sql/migrations/002_seed_doge_main.sql | 91 + .../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 | 226 ++ modules/mining-checker/sql/schema.pgsql.sql | 244 ++ modules/mining-checker/sql/schema.sql | 226 ++ modules/mining-checker/sql/seed.sql | 91 + modules/mining-checker/src/Api/Router.php | 1747 ++++++++++ .../src/Domain/AnalyticsService.php | 937 ++++++ .../mining-checker/src/Domain/FxService.php | 759 +++++ .../mining-checker/src/Domain/OcrService.php | 400 +++ .../mining-checker/src/Domain/SeedData.php | 79 + .../src/Domain/SeedImporter.php | 66 + .../src/Infrastructure/ConnectionFactory.php | 38 + .../src/Infrastructure/MiningRepository.php | 1400 ++++++++ .../src/Infrastructure/ModuleConfig.php | 66 + .../src/Infrastructure/SchemaManager.php | 1265 +++++++ .../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/landingpages/auth/callback.php | 7 +- partials/landingpages/auth/logout.php | 2 +- partials/landingpages/index.php | 100 +- partials/landingpages/modules/setup.php | 24 + partials/structure/layout_start.php | 6 +- public/assets/css/app.css | 383 +++ public/assets/images/kusche-logo.png | Bin 0 -> 27622 bytes public/assets/js/app.js | 49 + public/index.php | 154 +- src/App/App.php | 3 + src/App/AuthService.php | 172 + src/App/Config.php | 24 +- src/App/ModuleManager.php | 68 +- src/App/OidcClient.php | 2 +- src/App/SessionManager.php | 27 +- src/App/functions.php | 12 +- 53 files changed, 13388 insertions(+), 60 deletions(-) create mode 100644 modules/mining-checker/api/index.php create mode 100644 modules/mining-checker/assets/css/.gitkeep create mode 100644 modules/mining-checker/assets/css/app.css create mode 100644 modules/mining-checker/assets/js/.gitkeep create mode 100644 modules/mining-checker/assets/js/app.js 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/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 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 100755 public/assets/images/kusche-logo.png create mode 100644 src/App/AuthService.php diff --git a/modules/mining-checker/api/index.php b/modules/mining-checker/api/index.php new file mode 100644 index 0000000..ae69676 --- /dev/null +++ b/modules/mining-checker/api/index.php @@ -0,0 +1,6 @@ +handle($_GET['path'] ?? ''); diff --git a/modules/mining-checker/assets/css/.gitkeep b/modules/mining-checker/assets/css/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/modules/mining-checker/assets/css/app.css b/modules/mining-checker/assets/css/app.css new file mode 100644 index 0000000..56a7cc2 --- /dev/null +++ b/modules/mining-checker/assets/css/app.css @@ -0,0 +1,961 @@ +#mining-checker-app { + --mc-bg: #09111f; + --mc-surface: rgba(8, 15, 29, 0.74); + --mc-surface-strong: rgba(15, 23, 42, 0.92); + --mc-line: rgba(148, 163, 184, 0.18); + --mc-line-strong: rgba(255, 255, 255, 0.12); + --mc-text: #e5eef8; + --mc-text-muted: #a7b5c8; + --mc-accent: #3dd9c4; + --mc-accent-strong: #7dd3fc; + --mc-danger: #fb7185; + --mc-success: #34d399; + --mc-warning: #fbbf24; + min-height: 70vh; + background: + radial-gradient(circle at top left, rgba(13, 148, 136, 0.16), transparent 26%), + radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 22%), + linear-gradient(180deg, #04111d 0%, #0f172a 42%, #111827 100%); + color: var(--mc-text); + font-family: "Space Grotesk", "Segoe UI", sans-serif; + max-width: 100%; + overflow-x: clip; +} + +:root[data-theme="day"] #mining-checker-app { + --mc-bg: #f7fbfb; + --mc-surface: rgba(255, 255, 255, 0.84); + --mc-surface-strong: rgba(255, 255, 255, 0.96); + --mc-line: rgba(16, 33, 43, 0.13); + --mc-line-strong: color-mix(in srgb, var(--brand-accent, #ed1671) 32%, transparent); + --mc-text: #10212b; + --mc-text-muted: #66737b; + --mc-accent: var(--brand-accent, #ed1671); + --mc-accent-strong: var(--brand-accent-2, #06a9c8); + background: + radial-gradient(circle at top left, color-mix(in srgb, var(--brand-accent-2, #06a9c8) 16%, transparent), transparent 26%), + radial-gradient(circle at top right, color-mix(in srgb, var(--brand-accent, #ed1671) 16%, transparent), transparent 22%), + linear-gradient(180deg, #f7fbfb 0%, #eef7f5 48%, #fff8e9 100%); +} + +:root[data-theme="night"] #mining-checker-app { + --mc-accent: var(--brand-accent, #ed1671); + --mc-accent-strong: var(--brand-accent-2, #06a9c8); + background: + radial-gradient(circle at top left, color-mix(in srgb, var(--brand-accent-2, #06a9c8) 18%, transparent), transparent 26%), + radial-gradient(circle at top right, color-mix(in srgb, var(--brand-accent, #ed1671) 20%, transparent), transparent 22%), + linear-gradient(180deg, #050b12 0%, #0c1721 48%, #111827 100%); +} + +:root[data-theme="day"] #mining-checker-app .mc-stat-value { + color: var(--mc-text); +} + +:root[data-theme="day"] #mining-checker-app .mc-badge, +:root[data-theme="day"] #mining-checker-app .mc-badge--info, +:root[data-theme="day"] #mining-checker-app .mc-button--ghost { + color: var(--mc-text); +} + +:root[data-theme="day"] #mining-checker-app .mc-button--tab { + background: rgba(255, 255, 255, 0.72); + color: var(--mc-text); +} + +:root[data-theme="day"] #mining-checker-app .mc-button--tab-active, +:root[data-theme="day"] #mining-checker-app .mc-button--secondary { + background: var(--mc-text); + color: #ffffff; +} + +:root[data-theme="day"] #mining-checker-app .mc-input, +:root[data-theme="day"] #mining-checker-app .mc-select, +:root[data-theme="day"] #mining-checker-app .mc-textarea, +:root[data-theme="day"] #mining-checker-app .mc-file, +:root[data-theme="day"] #mining-checker-app .mc-display-field { + background: rgba(255, 255, 255, 0.72); +} + +:root[data-theme="day"] #mining-checker-app .mc-debug-entry, +:root[data-theme="day"] #mining-checker-app .mc-debug-text-console { + background: rgba(255, 255, 255, 0.7); +} + +:root[data-theme="day"] #mining-checker-app .mc-suggestion strong { + color: var(--mc-text); +} + +#mining-checker-app, +#mining-checker-app * { + box-sizing: border-box; +} + +#mining-checker-app .mc-grid-bg { + background-image: + linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px); + background-size: 24px 24px; + max-width: 100%; + overflow-x: clip; +} + +#mining-checker-app .mc-grid-bg[data-module-theme="custom"] { + --mc-bg: #09111f; + --mc-surface: rgba(8, 15, 29, 0.76); + --mc-surface-strong: rgba(15, 23, 42, 0.94); + --mc-line: rgba(148, 163, 184, 0.18); + --mc-line-strong: rgba(255, 255, 255, 0.12); + --mc-text: #e5eef8; + --mc-text-muted: #a7b5c8; + --mc-accent: #3dd9c4; + --mc-accent-strong: #7dd3fc; + color: var(--mc-text); + background: + linear-gradient(rgba(255, 255, 255, 0.04) 1px, transparent 1px), + linear-gradient(90deg, rgba(255, 255, 255, 0.04) 1px, transparent 1px), + radial-gradient(circle at top left, rgba(13, 148, 136, 0.16), transparent 26%), + radial-gradient(circle at top right, rgba(59, 130, 246, 0.18), transparent 22%), + linear-gradient(180deg, #04111d 0%, #0f172a 42%, #111827 100%); + background-size: 24px 24px, 24px 24px, auto, auto, auto; +} + +#mining-checker-app .mc-grid-bg[data-module-theme="custom"][data-module-accent="logo"] { + --mc-accent: #ed1671; + --mc-accent-strong: #06a9c8; +} + +#mining-checker-app .mc-grid-bg[data-module-theme="custom"][data-module-accent="pink"] { + --mc-accent: #ed1671; + --mc-accent-strong: #f6aa21; +} + +#mining-checker-app .mc-grid-bg[data-module-theme="custom"][data-module-accent="cyan"] { + --mc-accent: #06a9c8; + --mc-accent-strong: #8bc53f; +} + +#mining-checker-app .mc-grid-bg[data-module-theme="custom"][data-module-accent="orange"] { + --mc-accent: #f6aa21; + --mc-accent-strong: #ed1671; +} + +#mining-checker-app .mc-grid-bg[data-module-theme="custom"][data-module-accent="green"] { + --mc-accent: #8bc53f; + --mc-accent-strong: #06a9c8; +} + +#mining-checker-app .mc-shell { + width: min(1360px, calc(100% - 24px)); + max-width: 100%; + margin: 0 auto; + padding: 24px 0 40px; +} + +#mining-checker-app .mc-stack { + display: grid; + gap: 24px; +} + +#mining-checker-app .mc-hero, +#mining-checker-app .mc-panel, +#mining-checker-app .mc-stat-card, +#mining-checker-app .mc-dashboard-card, +#mining-checker-app .mc-target-card, +#mining-checker-app .mc-alert, +#mining-checker-app .mc-empty, +#mining-checker-app .mc-table-shell, +#mining-checker-app .mc-display-field { + border: 1px solid var(--mc-line); + border-radius: 28px; + background: var(--mc-surface); + box-shadow: 0 28px 60px rgba(0, 0, 0, 0.24); + backdrop-filter: blur(14px); +} + +#mining-checker-app .mc-hero, +#mining-checker-app .mc-panel, +#mining-checker-app .mc-dashboard-card, +#mining-checker-app .mc-alert, +#mining-checker-app .mc-empty, +#mining-checker-app .mc-table-shell { + padding: 24px; +} + +#mining-checker-app .mc-hero-top, +#mining-checker-app .mc-inline-row, +#mining-checker-app .mc-flex-split, +#mining-checker-app .mc-section-head { + display: flex; + gap: 16px; +} + +#mining-checker-app .mc-hero-top, +#mining-checker-app .mc-flex-split, +#mining-checker-app .mc-section-head { + justify-content: space-between; +} + +#mining-checker-app .mc-hero-copy, +#mining-checker-app .mc-hero-controls, +#mining-checker-app .mc-panel-body, +#mining-checker-app .mc-form, +#mining-checker-app .mc-field, +#mining-checker-app .mc-filter-grid, +#mining-checker-app .mc-chart, +#mining-checker-app .mc-target-grid { + display: grid; + gap: 14px; +} + +#mining-checker-app .mc-filter-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +#mining-checker-app .mc-hero-copy { + max-width: 860px; +} + +#mining-checker-app .mc-title { + margin: 0; + font-size: clamp(2rem, 4vw, 3.4rem); + line-height: 1.05; + letter-spacing: -0.04em; +} + +#mining-checker-app .mc-text, +#mining-checker-app p, +#mining-checker-app td, +#mining-checker-app th, +#mining-checker-app label, +#mining-checker-app summary, +#mining-checker-app pre { + color: var(--mc-text-muted); +} + +#mining-checker-app h1, +#mining-checker-app h2, +#mining-checker-app h3 { + margin: 0; + color: var(--mc-text); +} + +#mining-checker-app .mc-stats-grid, +#mining-checker-app .mc-target-grid, +#mining-checker-app .mc-overview-grid, +#mining-checker-app .mc-two-col, +#mining-checker-app .mc-main-grid { + display: grid; + gap: 16px; +} + +#mining-checker-app .mc-stats-grid { + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); +} + +#mining-checker-app .mc-overview-grid { + grid-template-columns: repeat(auto-fit, minmax(280px, 1fr)); +} + +#mining-checker-app .mc-target-grid { + grid-template-columns: repeat(auto-fit, minmax(260px, 1fr)); +} + +#mining-checker-app .mc-two-col { + grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); +} + +#mining-checker-app .mc-main-grid { + grid-template-columns: minmax(300px, 380px) minmax(0, 1fr); +} + +#mining-checker-app .mc-stat-card, +#mining-checker-app .mc-target-card { + padding: 20px; +} + +#mining-checker-app .mc-kicker { + font-size: 0.76rem; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--mc-accent-strong); +} + +#mining-checker-app .mc-stat-value { + font-size: 2rem; + font-weight: 700; + line-height: 1.1; + color: #fff; +} + +#mining-checker-app .mc-badge { + display: inline-flex; + align-items: center; + gap: 8px; + padding: 8px 12px; + border-radius: 999px; + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.18em; + border: 1px solid var(--mc-line-strong); + background: color-mix(in srgb, var(--mc-accent) 16%, transparent); + color: var(--mc-accent-strong); +} + +#mining-checker-app .mc-badge--warn { + background: rgba(251, 191, 36, 0.14); + color: #fde68a; +} + +#mining-checker-app .mc-badge--info { + background: color-mix(in srgb, var(--mc-accent) 16%, transparent); + color: var(--mc-accent-strong); +} + +#mining-checker-app .mc-badge--danger { + background: rgba(251, 113, 133, 0.14); + color: #fecdd3; +} + +#mining-checker-app .mc-badge--success { + background: rgba(52, 211, 153, 0.14); + color: #a7f3d0; +} + +#mining-checker-app .mc-tabs { + display: flex; + flex-wrap: wrap; + gap: 10px; + margin-top: 18px; +} + +#mining-checker-app .mc-button, +#mining-checker-app button, +#mining-checker-app input, +#mining-checker-app select, +#mining-checker-app textarea { + font: inherit; +} + +#mining-checker-app .mc-button { + border: 1px solid transparent; + border-radius: 16px; + padding: 12px 16px; + cursor: pointer; + text-decoration: none; + transition: 160ms ease; +} + +#mining-checker-app .mc-button:hover { + transform: translateY(-1px); +} + +#mining-checker-app .mc-button:disabled { + opacity: 0.6; + cursor: default; + transform: none; +} + +#mining-checker-app .mc-button--primary { + background: linear-gradient(135deg, var(--mc-accent), var(--mc-accent-strong)); + color: #05121f; + font-weight: 700; +} + +#mining-checker-app .mc-button--secondary { + background: rgba(255, 255, 255, 0.92); + color: #09111f; + font-weight: 700; +} + +#mining-checker-app .mc-button--danger { + background: linear-gradient(135deg, rgba(251, 113, 133, 0.92), rgba(239, 68, 68, 0.92)); + color: #fff7f7; + font-weight: 700; +} + +#mining-checker-app .mc-button--ghost { + background: color-mix(in srgb, var(--mc-accent) 14%, transparent); + border-color: color-mix(in srgb, var(--mc-accent) 34%, transparent); + color: var(--mc-accent-strong); +} + +#mining-checker-app .mc-home-link { + justify-self: end; +} + +#mining-checker-app .mc-debug-tools { + display: flex; + flex-wrap: wrap; + gap: 12px; + justify-content: flex-start; +} + +#mining-checker-app .mc-debug-console { + border-color: rgba(125, 211, 252, 0.28); +} + +#mining-checker-app .mc-debug-view-switch { + display: flex; + flex-wrap: wrap; + gap: 12px; + margin-bottom: 14px; +} + +#mining-checker-app .mc-debug-log { + display: grid; + gap: 12px; + max-height: 520px; + overflow: auto; +} + +#mining-checker-app .mc-debug-text-console { + max-height: 520px; + overflow: auto; + white-space: pre-wrap; + word-break: break-word; +} + +#mining-checker-app .mc-debug-entry { + border: 1px solid var(--mc-line); + border-radius: 18px; + padding: 14px 16px; + background: rgba(2, 6, 23, 0.7); +} + +#mining-checker-app .mc-button--tab { + background: rgba(255, 255, 255, 0.06); + color: var(--mc-text); +} + +#mining-checker-app .mc-button--tab-active { + background: rgba(255, 255, 255, 0.94); + color: #09111f; + font-weight: 700; +} + +#mining-checker-app .mc-field { + gap: 8px; +} + +#mining-checker-app .mc-field-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.18em; + color: var(--mc-text-muted); +} + +#mining-checker-app .mc-input, +#mining-checker-app .mc-select, +#mining-checker-app .mc-textarea, +#mining-checker-app .mc-file { + width: 100%; + border: 1px solid var(--mc-line); + border-radius: 16px; + padding: 13px 15px; + color: var(--mc-text); + background: rgba(255, 255, 255, 0.05); + outline: none; +} + +#mining-checker-app .mc-select option { + color: #09111f; + background: #f8fafc; +} + +#mining-checker-app .mc-textarea { + min-height: 120px; + resize: vertical; +} + +#mining-checker-app .mc-input::placeholder, +#mining-checker-app .mc-textarea::placeholder { + color: #6d7c90; +} + +#mining-checker-app .mc-input:focus, +#mining-checker-app .mc-select:focus, +#mining-checker-app .mc-textarea:focus, +#mining-checker-app .mc-file:focus { + border-color: rgba(125, 211, 252, 0.5); + box-shadow: 0 0 0 3px rgba(125, 211, 252, 0.12); +} + +#mining-checker-app .mc-checkbox { + display: flex; + align-items: center; + gap: 12px; + border: 1px solid var(--mc-line); + border-radius: 16px; + padding: 14px 16px; + background: rgba(255, 255, 255, 0.05); +} + +#mining-checker-app .mc-token-list, +#mining-checker-app .mc-suggestion-list { + display: flex; + flex-wrap: wrap; + gap: 10px; +} + +#mining-checker-app .mc-token-list--inline { + flex: 1 1 auto; +} + +#mining-checker-app .mc-currency-selection-row { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: flex-start; +} + +#mining-checker-app .mc-inline-fields { + display: flex; + flex-wrap: wrap; + gap: 16px; + align-items: end; +} + +#mining-checker-app .mc-inline-fields > .mc-field { + flex: 1 1 280px; +} + +#mining-checker-app .mc-currency-search { + flex: 0 1 360px; + min-width: 260px; +} + +#mining-checker-app .mc-token, +#mining-checker-app .mc-suggestion { + display: inline-flex; + align-items: center; + gap: 10px; + border: 1px solid var(--mc-line); + border-radius: 999px; + padding: 10px 14px; + background: rgba(255, 255, 255, 0.06); + color: var(--mc-text); +} + +#mining-checker-app .mc-token { + cursor: pointer; +} + +#mining-checker-app .mc-token:hover, +#mining-checker-app .mc-suggestion:hover { + border-color: rgba(125, 211, 252, 0.4); + background: rgba(61, 217, 196, 0.12); +} + +#mining-checker-app .mc-token-close { + color: var(--mc-accent-strong); + font-weight: 700; + text-transform: uppercase; +} + +#mining-checker-app .mc-suggestion { + cursor: pointer; +} + +@media (max-width: 860px) { + #mining-checker-app .mc-currency-selection-row { + flex-direction: column; + } + + #mining-checker-app .mc-currency-search { + flex: 1 1 auto; + width: 100%; + min-width: 0; + } +} + +#mining-checker-app .mc-suggestion strong { + color: #ffffff; +} + +#mining-checker-app .mc-alert--error { + border-color: rgba(251, 113, 133, 0.28); + background: rgba(127, 29, 29, 0.35); + color: #ffe4e6; +} + +#mining-checker-app .mc-alert--warning { + border-color: rgba(245, 158, 11, 0.28); + background: rgba(120, 53, 15, 0.34); + color: #fef3c7; +} + +#mining-checker-app .mc-alert--success { + border-color: rgba(52, 211, 153, 0.28); + background: rgba(6, 78, 59, 0.34); + color: #d1fae5; +} + +#mining-checker-app .mc-table-shell { + overflow: auto; + padding: 0; +} + +#mining-checker-app .mc-table { + width: 100%; + border-collapse: collapse; +} + +#mining-checker-app .mc-table th, +#mining-checker-app .mc-table td { + padding: 14px 16px; + border-bottom: 1px solid rgba(255, 255, 255, 0.06); + text-align: left; + vertical-align: top; +} + +#mining-checker-app .mc-table thead { + background: rgba(255, 255, 255, 0.04); +} + +#mining-checker-app .mc-empty { + border-style: dashed; +} + +#mining-checker-app details { + border: 1px solid var(--mc-line); + border-radius: 18px; + padding: 16px; + background: rgba(255, 255, 255, 0.04); +} + +#mining-checker-app pre { + white-space: pre-wrap; + margin: 12px 0 0; + line-height: 1.65; +} + +#mining-checker-app .mc-mini-grid { + display: grid; + gap: 10px; + grid-template-columns: repeat(auto-fit, minmax(160px, 1fr)); +} + +#mining-checker-app .mc-mini-card, +#mining-checker-app .mc-display-field { + padding: 12px 14px; + background: rgba(255, 255, 255, 0.04); +} + +#mining-checker-app .mc-code-block { + margin: 0; + padding: 12px 14px; + border: 1px solid var(--mc-line); + border-radius: 16px; + background: rgba(255, 255, 255, 0.03); + white-space: pre-wrap; + word-break: break-word; + font-family: "JetBrains Mono", "SFMono-Regular", monospace; + font-size: 0.92rem; + line-height: 1.6; +} + +#mining-checker-app .mc-chart svg { + width: 100%; + height: 220px; +} + +#mining-checker-app .mc-modal-backdrop { + position: fixed; + inset: 0; + z-index: 80; + display: flex; + align-items: center; + justify-content: center; + padding: 20px; + background: rgba(2, 6, 23, 0.72); +} + +#mining-checker-app .mc-modal { + width: min(720px, 100%); + max-height: min(80vh, 900px); + overflow: auto; + padding: 24px; + border: 1px solid var(--mc-line); + border-radius: 28px; + background: rgba(9, 17, 31, 0.96); + box-shadow: 0 28px 60px rgba(0, 0, 0, 0.34); + backdrop-filter: blur(14px); +} + +#mining-checker-app .mc-chart path, +#mining-checker-app .mc-chart polyline, +#mining-checker-app .mc-chart line, +#mining-checker-app .mc-chart rect { + vector-effect: non-scaling-stroke; +} + +@media (max-width: 960px) { + #mining-checker-app .mc-hero-top, + #mining-checker-app .mc-inline-row, + #mining-checker-app .mc-flex-split, + #mining-checker-app .mc-section-head { + flex-direction: column; + align-items: stretch; + } + + #mining-checker-app .mc-main-grid { + grid-template-columns: 1fr; + } + + #mining-checker-app .mc-shell { + width: min(100% - 10px, 1360px); + padding: 8px 0 20px; + } + + #mining-checker-app .mc-stack { + gap: 14px; + } + + #mining-checker-app .mc-hero, + #mining-checker-app .mc-panel, + #mining-checker-app .mc-dashboard-card, + #mining-checker-app .mc-alert, + #mining-checker-app .mc-empty, + #mining-checker-app .mc-table-shell { + padding: 14px; + border-radius: 20px; + } + + #mining-checker-app .mc-title { + font-size: clamp(1.45rem, 8vw, 2.15rem); + } + + #mining-checker-app .mc-hero-copy { + gap: 8px; + } + + #mining-checker-app .mc-hero-copy p { + margin: 0; + } + + #mining-checker-app .mc-hero-controls { + grid-template-columns: 1fr; + } + + #mining-checker-app .mc-home-link { + justify-self: stretch; + justify-content: center; + } + + #mining-checker-app .mc-tabs { + flex-wrap: nowrap; + gap: 8px; + margin: 12px -4px 0; + padding: 0 4px 4px; + overflow-x: auto; + scrollbar-width: thin; + } + + #mining-checker-app .mc-button { + padding: 10px 12px; + border-radius: 14px; + } + + #mining-checker-app .mc-button--tab { + flex: 0 0 auto; + white-space: nowrap; + } + + #mining-checker-app .mc-table th, + #mining-checker-app .mc-table td { + padding: 10px 12px; + } + + #mining-checker-app .mc-modal { + max-height: min(92vh, 900px); + padding: 16px; + border-radius: 20px; + } +} + +@media (max-width: 600px) { + #mining-checker-app, + #mining-checker-app * { + max-width: 100%; + } + + #mining-checker-app { + min-height: 100vh; + overflow-x: hidden; + } + + #mining-checker-app .mc-grid-bg { + overflow-x: hidden; + background-size: 18px 18px; + } + + #mining-checker-app .mc-shell { + width: 100%; + padding: 0 0 16px; + } + + #mining-checker-app .mc-stack { + gap: 12px; + } + + #mining-checker-app .mc-hero, + #mining-checker-app .mc-panel, + #mining-checker-app .mc-dashboard-card, + #mining-checker-app .mc-alert, + #mining-checker-app .mc-empty { + width: 100%; + border-radius: 18px; + padding: 14px; + box-shadow: 0 14px 32px rgba(0, 0, 0, 0.16); + } + + #mining-checker-app .mc-hero { + border-radius: 0 0 18px 18px; + } + + #mining-checker-app .mc-panel, + #mining-checker-app .mc-dashboard-card, + #mining-checker-app .mc-alert, + #mining-checker-app .mc-empty, + #mining-checker-app .mc-table-shell { + border-left: 0; + border-right: 0; + } + + #mining-checker-app .mc-title, + #mining-checker-app .mc-hero-copy p { + display: none; + } + + #mining-checker-app .mc-hero-top { + gap: 10px; + } + + #mining-checker-app .mc-kicker { + font-size: 0.68rem; + letter-spacing: 0.14em; + } + + #mining-checker-app .mc-tabs { + margin: 10px -6px 0; + padding: 0 6px 6px; + gap: 6px; + } + + #mining-checker-app .mc-button { + min-height: 38px; + padding: 8px 10px; + border-radius: 12px; + font-size: 0.92rem; + } + + #mining-checker-app .mc-button:hover { + transform: none; + } + + #mining-checker-app .mc-stats-grid, + #mining-checker-app .mc-target-grid, + #mining-checker-app .mc-overview-grid, + #mining-checker-app .mc-two-col, + #mining-checker-app .mc-main-grid, + #mining-checker-app .mc-filter-grid, + #mining-checker-app .mc-mini-grid { + grid-template-columns: minmax(0, 1fr); + gap: 10px; + } + + #mining-checker-app .mc-stat-card, + #mining-checker-app .mc-target-card, + #mining-checker-app .mc-mini-card, + #mining-checker-app .mc-display-field { + padding: 12px; + border-radius: 16px; + } + + #mining-checker-app .mc-stat-value { + font-size: clamp(1.45rem, 8vw, 1.85rem); + overflow-wrap: anywhere; + } + + #mining-checker-app h2 { + font-size: 1.25rem; + } + + #mining-checker-app h3 { + font-size: 1.05rem; + } + + #mining-checker-app .mc-text, + #mining-checker-app p, + #mining-checker-app td, + #mining-checker-app th, + #mining-checker-app label, + #mining-checker-app summary, + #mining-checker-app pre { + font-size: 0.92rem; + } + + #mining-checker-app .mc-input, + #mining-checker-app .mc-select, + #mining-checker-app .mc-textarea, + #mining-checker-app .mc-file { + min-width: 0; + padding: 10px 12px; + border-radius: 12px; + font-size: 16px; + } + + #mining-checker-app .mc-inline-fields, + #mining-checker-app .mc-currency-selection-row, + #mining-checker-app .mc-debug-tools, + #mining-checker-app .mc-debug-view-switch { + gap: 8px; + } + + #mining-checker-app .mc-inline-fields > .mc-field { + flex-basis: 100%; + min-width: 0; + } + + #mining-checker-app .mc-token, + #mining-checker-app .mc-suggestion, + #mining-checker-app .mc-badge { + padding: 7px 10px; + font-size: 0.78rem; + } + + #mining-checker-app .mc-table-shell { + width: 100%; + max-width: 100%; + border-radius: 16px; + overflow-x: auto; + -webkit-overflow-scrolling: touch; + } + + #mining-checker-app .mc-table { + max-width: none; + min-width: 640px; + } + + #mining-checker-app .mc-table th, + #mining-checker-app .mc-table td { + padding: 9px 10px; + font-size: 0.86rem; + } + + #mining-checker-app .mc-chart svg { + height: 180px; + } + + #mining-checker-app .mc-modal-backdrop { + align-items: stretch; + padding: 8px; + } + + #mining-checker-app .mc-modal { + width: 100%; + max-height: calc(100vh - 16px); + padding: 14px; + border-radius: 18px; + } +} diff --git a/modules/mining-checker/assets/js/.gitkeep b/modules/mining-checker/assets/js/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/modules/mining-checker/assets/js/app.js b/modules/mining-checker/assets/js/app.js new file mode 100644 index 0000000..0c2bb0c --- /dev/null +++ b/modules/mining-checker/assets/js/app.js @@ -0,0 +1,2983 @@ +(function () { + const root = document.getElementById('mining-checker-app'); + if (!root || !window.React || !window.ReactDOM) { + return; + } + + const h = React.createElement; + const { useEffect, useMemo, useState } = React; + const apiBase = root.dataset.apiBase || '/api/mining-checker/v1'; + const initialProjectKey = root.dataset.defaultProjectKey || 'doge-main'; + const fxProvider = root.dataset.fxProvider || 'currencyapi'; + const fxBaseUrl = root.dataset.fxUrl || 'https://currencyapi.net'; + const fxCurrenciesUrl = root.dataset.fxCurrenciesUrl || fxBaseUrl; + const fxApiKeyMask = root.dataset.fxApiKeyMask || ''; + const initialDebugMode = (() => { + try { + return window.localStorage.getItem('mining-checker-debug-enabled') === '1'; + } catch (error) { + return false; + } + })(); + const initialDebugConsoleOpen = (() => { + try { + return window.localStorage.getItem('mining-checker-debug-console-open') === '1'; + } catch (error) { + return false; + } + })(); + function getCookie(name) { + const pattern = `; ${document.cookie}`; + const parts = pattern.split(`; ${name}=`); + if (parts.length < 2) { + return ''; + } + return decodeURIComponent(parts.pop().split(';').shift() || ''); + } + + function setCookie(name, value, maxAgeSeconds) { + document.cookie = `${name}=${encodeURIComponent(value)}; path=/; max-age=${maxAgeSeconds}; samesite=lax`; + } + const initialDebugView = (() => { + try { + const value = window.localStorage.getItem('mining-checker-debug-view'); + return value === 'text' ? 'text' : 'structured'; + } catch (error) { + return 'structured'; + } + })(); + const debugBus = window.__miningCheckerDebugBus || { + enabled: initialDebugMode, + listener: null, + sequence: 0, + }; + window.__miningCheckerDebugBus = debugBus; + + function emitDebug(entry) { + debugBus.sequence += 1; + const payload = { + id: debugBus.sequence, + time: new Date().toISOString(), + ...entry, + }; + + if (typeof debugBus.listener === 'function') { + debugBus.listener(payload); + } + } + + function persistDebugCookie(enabled) { + const value = enabled ? '1' : '0'; + document.cookie = `mining_checker_debug=${value}; path=/; max-age=31536000; samesite=lax`; + } + + function emitServerTraceEntries(trace, meta) { + if (!Array.isArray(trace) || !trace.length) { + emitDebug({ + type: meta && meta.type ? meta.type : 'server:trace-empty', + source: meta && meta.source ? meta.source : null, + message: 'Keine Server-Debug-Eintraege vorhanden.', + }); + return; + } + + trace.forEach((item) => { + emitDebug({ + type: `server:${item.event || 'trace'}`, + source: meta && meta.source ? meta.source : null, + server_time: item.time || null, + ...(item.context && typeof item.context === 'object' ? item.context : {}), + }); + }); + } + + function buildExternalFxUrl(base) { + if (fxProvider === 'currencyapi') { + const params = new URLSearchParams({ + base: String(base || 'USD').toUpperCase(), + output: 'json', + }); + if (fxApiKeyMask) { + params.set('key', fxApiKeyMask); + } + return `${fxBaseUrl}/api/v2/rates?${params.toString()}`; + } + + return `${fxBaseUrl}/latest?base=${encodeURIComponent(String(base || 'USD').toUpperCase())}`; + } + + function buildExternalCurrenciesUrl() { + if (fxProvider === 'currencyapi') { + const params = new URLSearchParams({ output: 'json' }); + if (fxApiKeyMask) { + params.set('key', fxApiKeyMask); + } + return `${fxCurrenciesUrl}/api/v2/currencies?${params.toString()}`; + } + + return fxCurrenciesUrl; + } + + async function loadLatestDebugTrace() { + try { + const response = await fetch(`${apiBase}/debug/latest`, { + headers: { 'X-Mining-Debug': '1' }, + }); + const payload = await response.json().catch(() => ({})); + if (payload && payload.data && Array.isArray(payload.data.entries)) { + emitServerTraceEntries(payload.data.entries, { + type: 'server:trace-latest', + source: payload.data.file || null, + }); + } + } catch (error) { + emitDebug({ + type: 'server:trace-latest-error', + message: error && error.message ? error.message : 'Latest-Debug konnte nicht geladen werden', + }); + } + } + + function cx() { + return Array.from(arguments).filter(Boolean).join(' '); + } + + function fmtNumber(value, digits) { + if (value === null || value === undefined || value === '') { + return 'n/a'; + } + return Number(value).toLocaleString('de-DE', { + minimumFractionDigits: 0, + maximumFractionDigits: digits === undefined ? 6 : digits, + }); + } + + function fmtMoney(value, currency) { + if (value === null || value === undefined || !currency) { + return 'n/a'; + } + return new Intl.NumberFormat('de-DE', { + style: 'currency', + currency, + maximumFractionDigits: 4, + }).format(Number(value)); + } + + function fmtDate(value) { + if (!value) { + return 'n/a'; + } + return value.replace('T', ' ').slice(0, 16); + } + + function fmtDateTime(value) { + if (!value) { + return 'n/a'; + } + const normalized = String(value).replace('T', ' '); + return normalized.slice(0, 16); + } + + async function request(path, options) { + 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); + delete requestOptions.timeoutMs; + + const controller = new AbortController(); + const timeoutId = window.setTimeout(() => controller.abort(), timeoutMs); + + const requestUrl = path; + const headers = { ...(requestOptions.headers || {}) }; + if (debugEnabled) { + headers['X-Mining-Debug'] = '1'; + } + requestOptions.headers = headers; + + emitDebug({ + type: 'request:start', + method: requestOptions.method || 'GET', + url: requestUrl, + body: typeof requestOptions.body === 'string' ? requestOptions.body.slice(0, 2000) : null, + }); + + let response; + try { + response = await fetch(requestUrl, { ...requestOptions, signal: controller.signal }); + } catch (error) { + window.clearTimeout(timeoutId); + if (error && error.name === 'AbortError') { + emitDebug({ + type: 'request:timeout', + method: requestOptions.method || 'GET', + url: requestUrl, + timeout_ms: timeoutMs, + }); + if (debugEnabled) { + loadLatestDebugTrace(); + } + throw new Error('API request timeout'); + } + emitDebug({ + type: 'request:error', + method: requestOptions.method || 'GET', + url: requestUrl, + message: error && error.message ? error.message : 'Fetch fehlgeschlagen', + }); + throw error; + } + + window.clearTimeout(timeoutId); + const payload = await response.json().catch(() => ({})); + emitDebug({ + type: 'request:response', + method: requestOptions.method || 'GET', + url: requestUrl, + status: response.status, + ok: response.ok, + has_debug: Array.isArray(payload && payload.debug) && payload.debug.length > 0, + }); + if (debugEnabled && payload && payload.debug) { + emitServerTraceEntries(payload.debug, { + type: 'server:trace', + source: requestUrl, + }); + } + if (!response.ok) { + throw new Error(payload.error || 'API request failed'); + } + if (payload && Object.prototype.hasOwnProperty.call(payload, 'data')) { + return payload.data; + } + return payload; + } + + function normalizeBootstrap(data, projectKey) { + const normalized = data && typeof data === 'object' ? data : {}; + return { + project: normalized.project || { project_key: projectKey }, + settings: normalized.settings || { + project_key: projectKey, + baseline_measured_at: '', + baseline_coins_total: '', + daily_cost_amount: '', + daily_cost_currency: 'EUR', + report_currency: 'EUR', + crypto_currency: 'DOGE', + fx_max_age_hours: 3, + module_theme_mode: 'inherit', + module_theme_accent: 'teal', + preferred_currencies: ['DOGE', 'USD', 'EUR'], + cost_plans: [], + currencies: [], + payouts: [], + miner_offers: [], + purchased_miners: [], + measurement_rates: [], + }, + measurements: Array.isArray(normalized.measurements) ? normalized.measurements : [], + targets: Array.isArray(normalized.targets) ? normalized.targets : [], + dashboards: Array.isArray(normalized.dashboards) ? normalized.dashboards : [], + summary: normalized.summary || { + latest_measurement: null, + baseline: normalized.settings || null, + targets: Array.isArray(normalized.targets) ? normalized.targets : [], + payouts: { total_count: 0, total_coins: 0, current_visible_coins: null, current_effective_coins: null }, + current_hashrate_mh: null, + miner_offers: [], + }, + }; + } + + function normalizeSchemaStatus(data) { + const normalized = data && typeof data === 'object' ? data : {}; + return { + required_tables: Array.isArray(normalized.required_tables) ? normalized.required_tables : [], + present_tables: Array.isArray(normalized.present_tables) ? normalized.present_tables : [], + missing_tables: Array.isArray(normalized.missing_tables) ? normalized.missing_tables : [], + pending_upgrades: Array.isArray(normalized.pending_upgrades) ? normalized.pending_upgrades : [], + present_count: typeof normalized.present_count === 'number' ? normalized.present_count : 0, + missing_count: typeof normalized.missing_count === 'number' ? normalized.missing_count : 0, + pending_upgrade_count: typeof normalized.pending_upgrade_count === 'number' ? normalized.pending_upgrade_count : 0, + all_present: !!normalized.all_present, + }; + } + + function normalizeOcrPreview(data) { + const normalized = data && typeof data === 'object' ? data : {}; + const suggested = normalized.suggested && typeof normalized.suggested === 'object' + ? normalized.suggested + : {}; + + return { + suggested: { + measured_at: suggested.measured_at || '', + coins_total: suggested.coins_total ?? '', + price_per_coin: suggested.price_per_coin ?? '', + price_currency: suggested.price_currency || '', + note: suggested.note || '', + source: suggested.source || 'image_ocr', + }, + confidence: typeof normalized.confidence === 'number' ? normalized.confidence : 0, + flags: Array.isArray(normalized.flags) ? normalized.flags : [], + image_path: normalized.image_path || '', + raw_text: normalized.raw_text || '', + }; + } + + function getOcrStatusMessage(preview) { + const flags = Array.isArray(preview && preview.flags) ? preview.flags : []; + const missingProviders = flags + .filter((flag) => typeof flag === 'string' && flag.indexOf('ocr_provider_missing:') === 0) + .map((flag) => flag.split(':')[1]) + .filter(Boolean); + + if (flags.includes('ocr_engine_missing') || missingProviders.length) { + return { + tone: 'error', + text: missingProviders.length + ? `Auf dem Server ist kein nutzbarer OCR-Provider verfuegbar. Fehlend: ${missingProviders.join(', ')}. Bitte OCR.space oder Tesseract pruefen.` + : 'Auf dem Server ist kein nutzbarer OCR-Provider verfuegbar. Bitte OCR.space oder Tesseract pruefen.', + }; + } + + const emptyProviders = flags + .filter((flag) => typeof flag === 'string' && flag.indexOf('ocr_provider_empty:') === 0) + .map((flag) => flag.split(':')[1]) + .filter(Boolean); + + if (emptyProviders.length) { + return { + tone: 'warn', + text: `Der OCR-Provider ${emptyProviders.join(', ')} hat fuer diesen Screenshot keinen verwertbaren Rohtext geliefert.`, + }; + } + + if (flags.includes('ocr_raw_text_empty')) { + return { + tone: 'warn', + text: 'Es wurde kein OCR-Rohtext erkannt. Bitte Screenshot pruefen oder optionalen OCR-Hinweistext angeben.', + }; + } + + return null; + } + + function StatCard(props) { + return h('div', { className: 'mc-stat-card' }, [ + h('div', { key: 'label', className: 'mc-kicker' }, props.label), + h('div', { key: 'value', className: 'mc-stat-value' }, props.value), + props.sub ? h('div', { key: 'sub', className: 'mc-text' }, props.sub) : null, + ]); + } + + function Badge(props) { + return h('span', { + className: cx( + 'mc-badge', + props.tone === 'warn' ? 'mc-badge--warn' : + props.tone === 'danger' ? 'mc-badge--danger' : + props.tone === 'success' ? 'mc-badge--success' : + 'mc-badge--info' + ) + }, props.children); + } + + function SectionTitle(props) { + return h('div', { className: 'mc-section-head' }, [ + h('div', { key: 'copy' }, [ + h('h2', { key: 'title', className: 'mc-section-title' }, props.title), + props.subtitle ? h('p', { key: 'subtitle', className: 'mc-text' }, props.subtitle) : null, + ]), + props.action || null, + ]); + } + + function SimpleChart(props) { + const points = Array.isArray(props.data) ? props.data.filter((point) => point && point.y !== null && point.y !== undefined) : []; + if (!points.length) { + return h('div', { className: 'mc-empty' }, 'Keine Daten fuer diese Ansicht.'); + } + + if (props.type === 'table') { + return h('div', { className: 'mc-table-shell' }, [ + h('table', { key: 'table', className: 'mc-table' }, [ + h('thead', { key: 'head' }, h('tr', null, [ + h('th', { key: 'x' }, props.xLabel || 'X'), + h('th', { key: 'y' }, props.yLabel || 'Y'), + ])), + h('tbody', { key: 'body' }, + points.map((point, index) => h('tr', { key: index }, [ + h('td', { key: 'x' }, String(point.x)), + h('td', { key: 'y' }, fmtNumber(point.y, 6)), + ])) + ), + ]), + ]); + } + + const width = 640; + const height = 220; + const padding = 24; + const values = points.map((point) => Number(point.y)); + const minY = Math.min.apply(null, values); + const maxY = Math.max.apply(null, values); + const range = maxY - minY || 1; + const stepX = points.length > 1 ? (width - padding * 2) / (points.length - 1) : 0; + const coords = points.map((point, index) => { + const x = padding + stepX * index; + const y = height - padding - ((Number(point.y) - minY) / range) * (height - padding * 2); + return [x, y]; + }); + const line = coords.map((coord) => coord.join(',')).join(' '); + const area = coords.length + ? [[coords[0][0], height - padding]].concat(coords, [[coords[coords.length - 1][0], height - padding]]) + .map((coord) => coord.join(',')).join(' ') + : ''; + + return h('div', { className: 'mc-chart space-y-3' }, [ + h('div', { key: 'meta', className: 'mc-flex-split mc-kicker' }, [ + h('span', { key: 'min' }, 'Min ' + fmtNumber(minY, 4)), + h('span', { key: 'max' }, 'Max ' + fmtNumber(maxY, 4)), + ]), + h('svg', { key: 'svg', viewBox: `0 0 ${width} ${height}`, className: 'overflow-visible' }, [ + h('g', { key: 'grid', stroke: 'rgba(255,255,255,0.08)' }, [ + h('line', { key: 'top', x1: padding, x2: width - padding, y1: padding, y2: padding }), + h('line', { key: 'mid', x1: padding, x2: width - padding, y1: height / 2, y2: height / 2 }), + h('line', { key: 'base', x1: padding, x2: width - padding, y1: height - padding, y2: height - padding }), + ]), + props.type === 'area' ? h('polygon', { key: 'area', points: area, fill: 'rgba(45, 212, 191, 0.18)' }) : null, + props.type === 'bar' + ? h('g', { key: 'bars' }, coords.map((coord, index) => { + const barWidth = Math.max(10, stepX * 0.6 || 24); + return h('rect', { + key: index, + x: coord[0] - barWidth / 2, + y: coord[1], + width: barWidth, + height: height - padding - coord[1], + rx: 8, + fill: 'rgba(59, 130, 246, 0.75)', + }); + })) + : h('polyline', { + key: 'line', + points: line, + fill: 'none', + stroke: props.type === 'area' ? '#2dd4bf' : '#60a5fa', + strokeWidth: 3, + strokeLinecap: 'round', + strokeLinejoin: 'round', + }), + h('g', { key: 'dots' }, coords.map((coord, index) => h('circle', { + key: index, + cx: coord[0], + cy: coord[1], + r: 4, + fill: '#f8fafc', + stroke: props.type === 'area' ? '#2dd4bf' : '#60a5fa', + strokeWidth: 2, + }))), + ]), + h('div', { key: 'labels', className: 'mc-mini-grid' }, + points.slice(-3).map((point, index) => h('div', { key: index, className: 'mc-mini-card' }, `${point.x}: ${fmtNumber(point.y, 6)}`)) + ), + ]); + } + + function DashboardCard(props) { + return h('div', { className: 'mc-dashboard-card' }, [ + h('div', { key: 'head', className: 'mc-flex-split' }, [ + h('div', { key: 'titles' }, [ + h('h3', { key: 'name' }, props.definition.name), + h('p', { key: 'meta', className: 'mc-kicker' }, + `${props.definition.chart_type} · ${props.definition.x_field} → ${props.definition.y_field} · ${props.definition.aggregation}`), + ]), + h(Badge, { key: 'badge' }, props.definition.is_active ? 'aktiv' : 'inaktiv'), + ]), + props.loading + ? h('div', { key: 'loading', className: 'mc-empty' }, 'Lade Dashboarddaten …') + : h(SimpleChart, { + key: 'chart', + type: props.definition.chart_type, + data: props.data || [], + xLabel: props.definition.x_field, + yLabel: props.definition.y_field, + }), + ]); + } + + function App() { + const [projectKey, setProjectKey] = useState(initialProjectKey); + const [activeTab, setActiveTab] = useState('overview'); + const [payload, setPayload] = useState(() => normalizeBootstrap(null, initialProjectKey)); + const [dashboardData, setDashboardData] = useState({}); + const [loading, setLoading] = useState(true); + const [saving, setSaving] = useState(false); + const [error, setError] = useState(''); + const [message, setMessage] = useState(''); + const [debugEnabled, setDebugEnabled] = useState(initialDebugMode); + const [debugConsoleOpen, setDebugConsoleOpen] = useState(initialDebugConsoleOpen); + const [debugView, setDebugView] = useState(initialDebugView); + const [debugEntries, setDebugEntries] = useState([]); + const [schemaStatus, setSchemaStatus] = useState(normalizeSchemaStatus(null)); + const [initForm, setInitForm] = useState({ drop_existing: false }); + const [dbCheck, setDbCheck] = useState(null); + const [measurementForm, setMeasurementForm] = useState({ + measured_at: '', + coins_total: '', + price_per_coin: '', + price_currency: '', + note: '', + source: 'manual', + }); + const [importForm, setImportForm] = useState({ + rows_text: '', + default_currency: 'USD', + source: 'manual', + }); + const [importHelpOpen, setImportHelpOpen] = useState(false); + const [ocrForm, setOcrForm] = useState({ + image: null, + date_context: new Date().toISOString().slice(0, 10), + ocr_hint_text: '', + }); + const [ocrPreview, setOcrPreview] = useState(null); + const [dashboardForm, setDashboardForm] = useState({ + name: 'Neues Dashboard', + chart_type: 'line', + x_field: 'measured_at', + y_field: 'coins_total', + aggregation: 'none', + filters: { source: '', currency: '' }, + }); + const [settingsForm, setSettingsForm] = useState({ + baseline_measured_at: '', + baseline_coins_total: '', + report_currency: 'EUR', + crypto_currency: 'DOGE', + fx_max_age_hours: 3, + module_theme_mode: 'inherit', + module_theme_accent: 'teal', + }); + const [moduleAuthForm, setModuleAuthForm] = useState({ + required: true, + users: '', + groups: '', + }); + const [fxHistory, setFxHistory] = useState([]); + const [fxSelection, setFxSelection] = useState(['DOGE', 'USD', 'EUR']); + const [fxDisplayBase, setFxDisplayBase] = useState('USD'); + const [fxSearch, setFxSearch] = useState(''); + const [reportCurrencyOverride, setReportCurrencyOverride] = useState(() => { + const value = String(getCookie('mining_checker_report_currency') || '').toUpperCase(); + return /^[A-Z0-9]{3,10}$/.test(value) ? value : ''; + }); + const [targetForm, setTargetForm] = useState({ + label: '', + target_amount_fiat: '', + currency: 'EUR', + miner_offer_id: '', + is_active: true, + sort_order: 0, + }); + const [targetModalOpen, setTargetModalOpen] = useState(false); + const [selectedMinerScenarioId, setSelectedMinerScenarioId] = useState(null); + const [minerOfferFilters, setMinerOfferFilters] = useState({ + speed_min: '', + speed_unit: 'auto', + price_max: '', + runtime_months: '', + }); + const [costPlanForm, setCostPlanForm] = useState({ + label: '', + starts_at: '', + runtime_months: 1, + mining_speed_value: '', + mining_speed_unit: 'MH/s', + bonus_speed_value: '', + bonus_speed_unit: 'MH/s', + auto_renew: true, + base_price_amount: '', + payment_type: 'fiat', + total_cost_amount: '', + currency: 'EUR', + note: '', + is_active: true, + }); + const [costPlanModalOpen, setCostPlanModalOpen] = useState(false); + const [payoutForm, setPayoutForm] = useState({ + payout_at: '', + coins_amount: '', + payout_currency: 'DOGE', + note: '', + }); + const [payoutModalOpen, setPayoutModalOpen] = useState(false); + const [minerOfferForm, setMinerOfferForm] = useState({ + label: '', + runtime_months: '', + mining_speed_value: '', + mining_speed_unit: 'MH/s', + bonus_speed_value: '', + bonus_speed_unit: 'MH/s', + base_price_amount: '', + base_price_currency: 'USD', + payment_type: 'fiat', + auto_renew: false, + note: '', + is_active: true, + }); + const [minerOfferModalOpen, setMinerOfferModalOpen] = useState(false); + const [purchaseMinerModalOpen, setPurchaseMinerModalOpen] = useState(false); + const [purchaseMinerForm, setPurchaseMinerForm] = useState({ + offer_id: '', + purchased_at: '', + total_cost_amount: '', + currency: 'USD', + reference_price_amount: '', + reference_price_currency: '', + auto_renew: false, + note: '', + }); + + useEffect(() => { + debugBus.enabled = debugEnabled; + debugBus.listener = (entry) => { + setDebugEntries((current) => [entry].concat(current).slice(0, 250)); + }; + + try { + window.localStorage.setItem('mining-checker-debug-enabled', debugEnabled ? '1' : '0'); + } catch (error) { + // Ignore localStorage write failures. + } + persistDebugCookie(debugEnabled); + + emitDebug({ + type: 'debug:mode', + enabled: debugEnabled, + }); + + return () => { + debugBus.listener = null; + }; + }, [debugEnabled]); + + useEffect(() => { + try { + window.localStorage.setItem('mining-checker-debug-console-open', debugConsoleOpen ? '1' : '0'); + } catch (error) { + // Ignore localStorage write failures. + } + }, [debugConsoleOpen]); + + useEffect(() => { + try { + window.localStorage.setItem('mining-checker-debug-view', debugView); + } catch (error) { + // Ignore localStorage write failures. + } + }, [debugView]); + + const measurements = Array.isArray(payload?.measurements) ? payload.measurements : []; + const latest = payload?.summary?.latest_measurement || null; + const currentSettings = payload?.settings || { + cost_plans: [], + currencies: [], + }; + const reportCurrency = reportCurrencyOverride || currentSettings.report_currency || 'EUR'; + const currentTargets = Array.isArray(payload?.summary?.targets) ? payload.summary.targets : []; + const currentDashboards = Array.isArray(payload?.dashboards) ? payload.dashboards : []; + const currencies = Array.isArray(currentSettings.currencies) && currentSettings.currencies.length + ? currentSettings.currencies + : [ + { code: 'EUR', name: 'Euro' }, + { code: 'USD', name: 'US-Dollar' }, + { code: 'DOGE', name: 'Dogecoin' }, + { code: 'BTC', name: 'Bitcoin' }, + { code: 'ETH', name: 'Ethereum' }, + { code: 'LTC', name: 'Litecoin' }, + { code: 'USDT', name: 'Tether' }, + { code: 'USDC', name: 'USD Coin' }, + ]; + const currentCostPlans = Array.isArray(currentSettings.cost_plans) ? currentSettings.cost_plans : []; + const currentPayouts = Array.isArray(currentSettings.payouts) ? currentSettings.payouts : []; + const currentMinerOffers = Array.isArray(currentSettings.miner_offers) ? currentSettings.miner_offers : []; + const currentPurchasedMiners = Array.isArray(currentSettings.purchased_miners) ? currentSettings.purchased_miners : []; + const renewableOfferIds = new Set( + currentMinerOffers + .filter((offer) => !!offer.auto_renew) + .map((offer) => Number(offer.id)) + .filter((value) => Number.isFinite(value) && value > 0) + ); + const preferredCurrencyCodes = Array.isArray(currentSettings.preferred_currencies) + ? currentSettings.preferred_currencies.map((code) => String(code || '').toUpperCase()).filter(Boolean) + : []; + const preferredCurrencySet = new Set(preferredCurrencyCodes); + const preferredSelectableCurrencies = preferredCurrencySet.size + ? currencies.filter((currency) => preferredCurrencySet.has(String(currency.code || '').toUpperCase())) + : currencies; + const selectableCurrencies = preferredSelectableCurrencies.length ? preferredSelectableCurrencies : currencies; + const evaluatedMinerOffers = Array.isArray(payload?.summary?.miner_offers) ? payload.summary.miner_offers : []; + const availableMinerOffers = evaluatedMinerOffers.filter((offer) => !!offer.is_active); + const filteredMinerOffers = availableMinerOffers.filter((offer) => { + const speedMin = minerOfferFilters.speed_min === '' ? null : Number(minerOfferFilters.speed_min); + const speedUnit = String(minerOfferFilters.speed_unit || 'auto'); + const priceMax = minerOfferFilters.price_max === '' ? null : Number(minerOfferFilters.price_max); + const runtimeMonths = minerOfferFilters.runtime_months === '' ? null : Number(minerOfferFilters.runtime_months); + const offerHashrate = Number(offer.offer_hashrate_mh); + const comparablePrice = convertCurrencyValue( + offer.base_price_amount ?? offer.effective_price_amount, + offer.base_price_currency || offer.effective_price_currency, + reportCurrency + ); + const runtime = Number(offer.runtime_months); + const comparableHashrate = speedUnit === 'kh' + ? offerHashrate * 1000 + : offerHashrate; + + if (Number.isFinite(speedMin) && (!Number.isFinite(comparableHashrate) || comparableHashrate < speedMin)) { + return false; + } + + if (Number.isFinite(priceMax) && (!Number.isFinite(comparablePrice) || comparablePrice > priceMax)) { + return false; + } + + if (Number.isFinite(runtimeMonths) && runtime !== runtimeMonths) { + return false; + } + + return true; + }); + const speedUnits = ['kH/s', 'MH/s']; + const fiatCurrencies = currencies.filter((currency) => !currency.is_crypto); + const cryptoCurrencies = currencies.filter((currency) => !!currency.is_crypto); + const preferredSelectableFiatCurrencies = preferredCurrencySet.size + ? fiatCurrencies.filter((currency) => preferredCurrencySet.has(String(currency.code || '').toUpperCase())) + : fiatCurrencies; + const preferredSelectableCryptoCurrencies = preferredCurrencySet.size + ? cryptoCurrencies.filter((currency) => preferredCurrencySet.has(String(currency.code || '').toUpperCase())) + : cryptoCurrencies; + const selectableFiatCurrencies = preferredSelectableFiatCurrencies.length ? preferredSelectableFiatCurrencies : fiatCurrencies; + const selectableCryptoCurrencies = preferredSelectableCryptoCurrencies.length ? preferredSelectableCryptoCurrencies : cryptoCurrencies; + const selectedMinerScenario = availableMinerOffers.find((offer) => Number(offer.id) === Number(selectedMinerScenarioId)) || null; + const activeMinerRows = currentPurchasedMiners.map((miner) => ({ + id: `purchase-${miner.id}`, + source: 'miete', + starts_at: miner.purchased_at, + label: miner.label, + runtime_months: miner.runtime_months, + auto_renew: !!miner.auto_renew, + effective_amount: miner.total_cost_amount, + effective_currency: miner.currency, + base_amount: miner.reference_price_amount, + base_currency: miner.reference_price_currency, + miner_id: miner.id, + miner_offer_id: miner.miner_offer_id, + payment_type: miner.reference_price_currency + && miner.currency + && String(miner.reference_price_currency).toUpperCase() !== String(miner.currency).toUpperCase() + ? 'crypto' + : 'fiat', + is_active: miner.is_active !== false, + can_toggle_auto_renew: Number(miner.runtime_months) > 0 && renewableOfferIds.has(Number(miner.miner_offer_id)), + hashrate_text: `${fmtNumber(((Number(miner.mining_speed_value) || 0) + (Number(miner.bonus_speed_value) || 0)), 4)} ${miner.mining_speed_unit || miner.bonus_speed_unit || ''}`.trim(), + type_label: 'Aus Angebot gemietet', + })).concat(currentCostPlans.map((plan) => ({ + id: `plan-${plan.id}`, + source: 'manual', + starts_at: plan.starts_at, + label: plan.label, + runtime_months: plan.runtime_months, + auto_renew: !!plan.auto_renew, + effective_amount: plan.total_cost_amount, + effective_currency: plan.currency, + base_amount: plan.base_price_amount, + base_currency: currentSettings.report_currency || 'EUR', + payment_type: plan.payment_type, + is_active: !!plan.is_active, + can_toggle_auto_renew: false, + hashrate_text: [ + formatSpeed(plan.mining_speed_value, plan.mining_speed_unit, 'Basis'), + formatSpeed(plan.bonus_speed_value, plan.bonus_speed_unit, 'Bonus'), + ].filter(Boolean).join(' · ') || 'n/a', + type_label: 'Manuell eingetragen', + }))).sort((left, right) => String(right.starts_at || '').localeCompare(String(left.starts_at || ''))); + + useEffect(() => { + if (reportCurrencyOverride) { + setCookie('mining_checker_report_currency', reportCurrencyOverride, 60 * 60 * 24 * 30); + } + }, [reportCurrencyOverride]); + + useEffect(() => { + if (selectedMinerScenarioId === null) { + return; + } + + const exists = availableMinerOffers.some((offer) => Number(offer.id) === Number(selectedMinerScenarioId)); + if (!exists) { + setSelectedMinerScenarioId(null); + } + }, [availableMinerOffers, selectedMinerScenarioId]); + + useEffect(() => { + if (!purchaseMinerModalOpen) { + return; + } + + const selectedOffer = availableMinerOffers.find((offer) => String(offer.id) === String(purchaseMinerForm.offer_id)) + || availableMinerOffers[0] + || null; + if (!selectedOffer) { + return; + } + + setPurchaseMinerForm((current) => ({ + ...current, + offer_id: current.offer_id || String(selectedOffer.id), + currency: current.currency || selectedOffer.effective_price_currency || selectedOffer.base_price_currency || 'USD', + total_cost_amount: current.total_cost_amount || (selectedOffer.effective_price_amount !== null && selectedOffer.effective_price_amount !== undefined ? String(selectedOffer.effective_price_amount) : ''), + reference_price_amount: current.reference_price_amount || (selectedOffer.reference_price_amount !== null && selectedOffer.reference_price_amount !== undefined ? String(selectedOffer.reference_price_amount) : ''), + reference_price_currency: current.reference_price_currency || selectedOffer.reference_price_currency || '', + auto_renew: current.auto_renew || !!selectedOffer.auto_renew, + })); + }, [purchaseMinerModalOpen, availableMinerOffers, purchaseMinerForm.offer_id]); + + function measurementFxRate(measurementId, fromCurrency, toCurrency) { + const from = String(fromCurrency || '').toUpperCase(); + const to = String(toCurrency || '').toUpperCase(); + if (!from || !to) { + return null; + } + if (from === to) { + return 1; + } + + const measurementRates = Array.isArray(currentSettings.measurement_rates) ? currentSettings.measurement_rates : []; + const direct = measurementRates.find((row) => + Number(row.measurement_id) === Number(measurementId) + && String(row.base_currency || '').toUpperCase() === from + && String(row.target_currency || row.quote_currency || '').toUpperCase() === to + ); + if (direct) { + const value = Number(direct.rate); + if (Number.isFinite(value) && value > 0) { + return value; + } + } + + const inverse = measurementRates.find((row) => + Number(row.measurement_id) === Number(measurementId) + && String(row.base_currency || '').toUpperCase() === to + && String(row.target_currency || row.quote_currency || '').toUpperCase() === from + ); + if (inverse) { + const value = Number(inverse.rate); + if (Number.isFinite(value) && value > 0) { + return 1 / value; + } + } + + return null; + } + + function convertMeasurementMoney(measurement, value, targetCurrency) { + if (!measurement || value === null || value === undefined) { + return null; + } + + const sourceCurrency = String(measurement.effective_price_currency || measurement.price_currency || '').toUpperCase(); + const target = String(targetCurrency || '').toUpperCase(); + const numericValue = Number(value); + if (!sourceCurrency || !target || !Number.isFinite(numericValue)) { + return null; + } + if (sourceCurrency === target) { + return numericValue; + } + + const rate = measurementFxRate(measurement.id, sourceCurrency, target) ?? latestFxHistoryRate(sourceCurrency, target); + return rate === null ? null : numericValue * rate; + } + + function convertCurrencyValue(value, sourceCurrency, targetCurrency) { + const from = String(sourceCurrency || '').toUpperCase(); + const to = String(targetCurrency || '').toUpperCase(); + const numericValue = Number(value); + if (!from || !to || !Number.isFinite(numericValue)) { + return null; + } + if (from === to) { + return numericValue; + } + + const rate = latest && latest.id + ? (measurementFxRate(latest.id, from, to) ?? latestFxHistoryRate(from, to)) + : latestFxHistoryRate(from, to); + return rate === null ? null : numericValue * rate; + } + + function latestFxHistoryRate(fromCurrency, toCurrency) { + const from = String(fromCurrency || '').toUpperCase(); + const to = String(toCurrency || '').toUpperCase(); + if (!from || !to) { + return null; + } + if (from === to) { + return 1; + } + + const rows = Array.isArray(fxHistory) ? fxHistory : []; + for (const row of rows) { + const rowBase = String(row.base_currency || '').toUpperCase(); + const rowTarget = String(row.target_currency || row.currency_code || '').toUpperCase(); + const rowRate = Number(row.rate); + if (!Number.isFinite(rowRate) || rowRate <= 0) { + continue; + } + + if (rowBase === from && rowTarget === to) { + return rowRate; + } + if (rowBase === to && rowTarget === from) { + return 1 / rowRate; + } + } + + return null; + } + + async function loadSchemaStatus(key) { + try { + const schema = await request(`${apiBase}/projects/${encodeURIComponent(key)}/schema-status`, { timeoutMs: 4000 }); + setSchemaStatus(normalizeSchemaStatus(schema)); + } catch (err) { + setSchemaStatus(normalizeSchemaStatus(null)); + } + } + + async function loadBootstrap(key) { + setLoading(true); + setError(''); + setPayload((previous) => previous || normalizeBootstrap(null, key)); + let loadGuardTriggered = false; + const loadGuard = window.setTimeout(() => { + loadGuardTriggered = true; + setLoading(false); + setPayload((previous) => previous || normalizeBootstrap(null, key)); + setError((previous) => previous || 'Bootstrap-Request haengt oder braucht zu lange.'); + }, 12000); + + try { + const data = await request(`${apiBase}/projects/${encodeURIComponent(key)}/bootstrap`, { timeoutMs: 10000 }); + const normalized = normalizeBootstrap(data, key); + setPayload(normalized); + setSettingsForm({ + baseline_measured_at: normalized.settings.baseline_measured_at || '', + baseline_coins_total: normalized.settings.baseline_coins_total || '', + report_currency: normalized.settings.report_currency || 'EUR', + crypto_currency: normalized.settings.crypto_currency || 'DOGE', + fx_max_age_hours: normalized.settings.fx_max_age_hours || 3, + module_theme_mode: normalized.settings.module_theme_mode || 'inherit', + module_theme_accent: normalized.settings.module_theme_accent || 'teal', + }); + setFxSelection(Array.isArray(normalized.settings.preferred_currencies) && normalized.settings.preferred_currencies.length + ? normalized.settings.preferred_currencies + : ['DOGE', 'USD', 'EUR']); + loadFxHistory(key); + setTargetForm((previous) => ({ + ...previous, + currency: normalized.settings.currencies?.[0]?.code || previous.currency || 'EUR', + })); + setCostPlanForm((previous) => ({ + ...previous, + currency: normalized.settings.currencies?.[0]?.code || previous.currency || 'EUR', + })); + } catch (err) { + setError(err.message); + setPayload(normalizeBootstrap(null, key)); + } finally { + window.clearTimeout(loadGuard); + if (!loadGuardTriggered) { + setLoading(false); + } + } + } + + useEffect(() => { + loadBootstrap(projectKey); + }, [projectKey]); + + useEffect(() => { + if (activeTab === 'settings') { + loadSchemaStatus(projectKey); + loadModuleAuth(); + } + }, [activeTab, projectKey]); + + useEffect(() => { + if (activeTab === 'currencies') { + loadFxHistory(projectKey); + } + }, [activeTab, projectKey]); + + useEffect(() => { + async function loadSavedDashboards() { + if (!payload || !currentDashboards.length) { + return; + } + + const next = {}; + for (const definition of currentDashboards) { + const params = new URLSearchParams({ + x_field: definition.x_field, + y_field: definition.y_field, + aggregation: definition.aggregation || 'none', + }); + if (definition.filters && definition.filters.source) { + params.set('source', definition.filters.source); + } + if (definition.filters && definition.filters.currency) { + params.set('currency', definition.filters.currency); + } + + try { + next[definition.id] = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/dashboard-data?${params.toString()}`); + } catch (err) { + next[definition.id] = []; + } + } + setDashboardData(next); + } + + loadSavedDashboards(); + }, [payload, projectKey]); + + const overviewCharts = useMemo(() => ({ + mining: measurements.map((row) => ({ x: row.measured_at.slice(5, 16), y: row.coins_total })), + performance: measurements.filter((row) => row.doge_per_day_interval !== null) + .map((row) => ({ x: row.measured_at.slice(5, 16), y: row.doge_per_day_interval })), + pricing: measurements.filter((row) => row.price_per_coin !== null) + .map((row) => ({ x: row.measured_at.slice(5, 16), y: row.price_per_coin })), + }), [measurements]); + + async function submitMeasurement(fromPreview) { + const preview = normalizeOcrPreview(ocrPreview); + const raw = fromPreview ? { + ...preview.suggested, + image_path: preview.image_path, + ocr_raw_text: preview.raw_text, + ocr_confidence: preview.confidence, + ocr_flags: preview.flags, + } : measurementForm; + + setSaving(true); + setError(''); + setMessage(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/measurements`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(raw), + }); + setMessage(fromPreview ? 'OCR-Vorschlag bestaetigt und gespeichert.' : 'Messpunkt gespeichert.'); + setMeasurementForm({ + measured_at: '', + coins_total: '', + price_per_coin: '', + price_currency: '', + note: '', + source: 'manual', + }); + setOcrPreview(null); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitMeasurementImport(event) { + event.preventDefault(); + setSaving(true); + setError(''); + setMessage(''); + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/measurements-import`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(importForm), + timeoutMs: 20000, + }); + + const summary = [ + `${result.imported || 0} importiert`, + `${result.duplicates_ignored || 0} Duplikate ignoriert`, + `${result.error_count || 0} Fehler`, + ].join(', '); + + setMessage(`Import abgeschlossen: ${summary}.`); + if (!result.error_count) { + setImportForm((previous) => ({ ...previous, rows_text: '' })); + } + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function loadOcrPreview(file, overrides) { + const nextForm = { + ...ocrForm, + ...(overrides || {}), + image: file || null, + }; + + if (!nextForm.image) { + setOcrPreview(null); + setError('Bitte ein Bild auswaehlen.'); + return; + } + + setOcrForm(nextForm); + setSaving(true); + setError(''); + setMessage(''); + try { + const body = new FormData(); + body.append('image', nextForm.image); + body.append('date_context', nextForm.date_context); + body.append('ocr_hint_text', nextForm.ocr_hint_text); + const data = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/ocr-preview`, { + method: 'POST', + body, + }); + setOcrPreview(normalizeOcrPreview(data)); + setMessage('OCR-Ergebnis geladen. Bei Bedarf direkt speichern.'); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitDashboard(event) { + event.preventDefault(); + setSaving(true); + setError(''); + setMessage(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/dashboards`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + ...dashboardForm, + is_active: true, + filters: dashboardForm.filters, + }), + }); + setMessage('Dashboard gespeichert.'); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitSettings(event) { + event.preventDefault(); + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + baseline_measured_at: settingsForm.baseline_measured_at, + baseline_coins_total: settingsForm.baseline_coins_total, + daily_cost_amount: currentSettings.daily_cost_amount, + daily_cost_currency: currentSettings.daily_cost_currency, + report_currency: settingsForm.report_currency || 'EUR', + crypto_currency: settingsForm.crypto_currency || 'DOGE', + fx_max_age_hours: settingsForm.fx_max_age_hours || 3, + module_theme_mode: settingsForm.module_theme_mode || 'inherit', + module_theme_accent: settingsForm.module_theme_accent || 'teal', + preferred_currencies: Array.isArray(currentSettings.preferred_currencies) + ? currentSettings.preferred_currencies + : fxSelection, + }), + }); + setMessage('Settings gespeichert.'); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function loadModuleAuth() { + try { + const auth = await request('/api/module-auth/mining-checker', { timeoutMs: 5000 }); + setModuleAuthForm({ + required: !!auth.required, + users: Array.isArray(auth.users) ? auth.users.join(', ') : '', + groups: Array.isArray(auth.groups) ? auth.groups.join(', ') : '', + }); + } catch (err) { + setError(err.message); + } + } + + async function submitModuleAuth(event) { + event.preventDefault(); + setSaving(true); + setError(''); + try { + await request('/api/module-auth/mining-checker', { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + required: !!moduleAuthForm.required, + users: moduleAuthForm.users, + groups: moduleAuthForm.groups, + }), + }); + setMessage('Modulrechte gespeichert.'); + await loadModuleAuth(); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitTarget(event) { + event.preventDefault(); + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/targets`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(targetForm), + }); + 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); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function deleteTarget(target) { + const label = target?.label || 'dieses Ziel'; + if (!window.confirm(`Soll ${label} wirklich geloescht werden?`)) { + return; + } + + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/targets/${encodeURIComponent(target.id)}`, { + method: 'DELETE', + }); + setMessage('Ziel geloescht.'); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function togglePurchasedMinerAutoRenew(row) { + if (!row || !row.can_toggle_auto_renew || !row.miner_id) { + return; + } + + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/purchased-miners/${encodeURIComponent(row.miner_id)}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ auto_renew: !row.auto_renew }), + }); + setMessage(`Automatische Verlängerung ${row.auto_renew ? 'deaktiviert' : 'aktiviert'}.`); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitCostPlan(event) { + event.preventDefault(); + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/cost-plans`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(costPlanForm), + }); + setMessage('Miner gespeichert.'); + setCostPlanForm({ + label: '', + starts_at: '', + runtime_months: 1, + mining_speed_value: '', + mining_speed_unit: 'MH/s', + bonus_speed_value: '', + bonus_speed_unit: 'MH/s', + auto_renew: true, + base_price_amount: '', + payment_type: 'fiat', + total_cost_amount: '', + currency: currencies[0]?.code || 'EUR', + note: '', + is_active: true, + }); + setCostPlanModalOpen(false); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitPayout(event) { + event.preventDefault(); + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/payouts`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payoutForm), + }); + setMessage('Auszahlung gespeichert.'); + setPayoutForm({ payout_at: '', coins_amount: '', payout_currency: 'DOGE', note: '' }); + setPayoutModalOpen(false); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitMinerOffer(event) { + event.preventDefault(); + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/miner-offers`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(minerOfferForm), + }); + setMessage('Miner-Angebot gespeichert.'); + setMinerOfferForm({ + label: '', + runtime_months: '', + mining_speed_value: '', + mining_speed_unit: 'MH/s', + bonus_speed_value: '', + bonus_speed_unit: 'MH/s', + base_price_amount: '', + base_price_currency: 'USD', + payment_type: 'fiat', + auto_renew: false, + note: '', + is_active: true, + }); + setMinerOfferModalOpen(false); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function purchaseMinerOffer(offerId, overrides) { + setSaving(true); + setError(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/miner-offers/${offerId}/purchase`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(overrides || { purchased_at: new Date().toISOString().slice(0, 19).replace('T', ' ') }), + }); + setMessage('Miner als gemietet erfasst.'); + setPurchaseMinerModalOpen(false); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function submitPurchaseMiner(event) { + event.preventDefault(); + if (!purchaseMinerForm.offer_id) { + setError('Bitte ein Miner-Angebot auswaehlen.'); + return; + } + + await purchaseMinerOffer(purchaseMinerForm.offer_id, { + purchased_at: purchaseMinerForm.purchased_at || new Date().toISOString().slice(0, 19).replace('T', ' '), + total_cost_amount: purchaseMinerForm.total_cost_amount || null, + currency: purchaseMinerForm.currency || null, + reference_price_amount: purchaseMinerForm.reference_price_amount || null, + reference_price_currency: purchaseMinerForm.reference_price_currency || null, + auto_renew: !!purchaseMinerForm.auto_renew, + note: purchaseMinerForm.note || '', + }); + } + + async function initializeModule(event) { + event.preventDefault(); + setSaving(true); + setError(''); + setMessage(''); + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/initialize`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(initForm), + }); + const nextStatus = normalizeSchemaStatus(result.after); + setSchemaStatus(nextStatus); + setMessage( + `${result.message} Vorhanden: ${nextStatus.present_count}/${nextStatus.required_tables.length}. ` + + (Array.isArray(result.dropped_tables) && result.dropped_tables.length + ? `Geloeschte Tabellen: ${result.dropped_tables.join(', ')}.` + : 'Keine Tabellen geloescht.') + ); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function upgradeDatabaseSchema() { + setSaving(true); + setError(''); + setMessage(''); + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/upgrade`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + }); + const nextStatus = normalizeSchemaStatus(result.after); + setSchemaStatus(nextStatus); + setMessage( + `${result.message} ` + + (Array.isArray(result.upgraded) && result.upgraded.length + ? `Angewendete Upgrades: ${result.upgraded.join(', ')}.` + : 'Keine Upgrades erforderlich.') + ); + await loadBootstrap(projectKey); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function testDatabaseConnection() { + setSaving(true); + setError(''); + setMessage(''); + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/connection-test`); + setDbCheck(result); + setMessage(`DB-Verbindung erfolgreich. Driver: ${result.driver}, Datenbank: ${result.database}.`); + } catch (err) { + setDbCheck(null); + setError(err.message); + } finally { + setSaving(false); + } + } + + async function loadFxHistory(key) { + try { + const result = await request(`${apiBase}/projects/${encodeURIComponent(key)}/fx-history`, { timeoutMs: 6000 }); + setFxHistory(Array.isArray(result) ? result : []); + } catch (err) { + setFxHistory([]); + } + } + + async function refreshSelectedFxRates() { + setSaving(true); + setError(''); + setMessage(''); + try { + emitDebug({ + type: 'external:request-plan', + label: 'Ich rufe jetzt extern FX-Rates auf', + provider: fxProvider, + url: buildExternalFxUrl('USD'), + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const probe = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/fx-probe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ base: 'USD' }), + timeoutMs: 20000, + }); + emitDebug({ + type: 'external:response', + label: 'Hey, hier ist der Response vor dem DB-Schritt', + url: probe.url, + http_status: probe.http_status, + curl_error: probe.curl_error, + response_headers: probe.response_headers, + response_body: probe.response_body, + }); + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/fx-refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + base: 'USD', + }), + timeoutMs: 15000, + }); + await loadFxHistory(projectKey); + await loadBootstrap(projectKey); + setMessage(`Wechselkurse aktualisiert. ${result.updated_count || 0} Datensaetze gespeichert.`); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function refreshCurrencyCatalog() { + setSaving(true); + setError(''); + setMessage(''); + try { + emitDebug({ + type: 'external:request-plan', + label: 'Ich rufe jetzt extern den Waehrungskatalog auf', + provider: fxProvider, + url: buildExternalCurrenciesUrl(), + method: 'GET', + headers: { Accept: 'application/json' }, + }); + const probe = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/currencies-probe`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + timeoutMs: 20000, + }); + emitDebug({ + type: 'external:response', + label: 'Hey, hier ist der Response vor dem DB-Schritt', + url: probe.url, + http_status: probe.http_status, + curl_error: probe.curl_error, + response_headers: probe.response_headers, + response_body: probe.response_body, + }); + const result = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/currencies-refresh`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + timeoutMs: 20000, + }); + setPayload((current) => { + const next = normalizeBootstrap(current, projectKey); + return { + ...next, + settings: { + ...next.settings, + currencies: Array.isArray(result.currencies) ? result.currencies : next.settings.currencies, + }, + }; + }); + setMessage(`Waehrungskatalog synchronisiert. ${result.synced_count || 0} Waehrungen verarbeitet.`); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function saveFxSelection() { + setSaving(true); + setError(''); + setMessage(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/settings`, { + method: 'PUT', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + baseline_measured_at: currentSettings.baseline_measured_at, + baseline_coins_total: currentSettings.baseline_coins_total, + daily_cost_amount: currentSettings.daily_cost_amount, + daily_cost_currency: currentSettings.daily_cost_currency, + report_currency: currentSettings.report_currency || 'EUR', + fx_max_age_hours: currentSettings.fx_max_age_hours || 3, + preferred_currencies: fxSelection, + }), + }); + await loadBootstrap(projectKey); + setMessage('Waehrungs-Auswahl gespeichert.'); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function copyDebugConsole() { + const content = debugEntries + .slice() + .reverse() + .map((entry) => `${entry.time} · ${entry.type}\n${JSON.stringify(entry, null, 2)}`) + .join('\n\n'); + + if (!content) { + setMessage('Keine Debug-Ausgaben zum Kopieren vorhanden.'); + return; + } + + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(content); + } else { + const textarea = document.createElement('textarea'); + textarea.value = content; + textarea.setAttribute('readonly', 'readonly'); + textarea.style.position = 'absolute'; + textarea.style.left = '-9999px'; + document.body.appendChild(textarea); + textarea.select(); + document.execCommand('copy'); + document.body.removeChild(textarea); + } + + setMessage('Debug-Inhalt wurde in die Zwischenablage kopiert.'); + } catch (err) { + setError('Debug-Inhalt konnte nicht kopiert werden.'); + } + } + + function addFxSelection(code) { + const normalized = String(code || '').toUpperCase().trim(); + if (!normalized) { + return; + } + + setFxSelection((current) => current.includes(normalized) ? current : current.concat([normalized])); + setFxSearch(''); + } + + function removeFxSelection(code) { + const normalized = String(code || '').toUpperCase().trim(); + setFxSelection((current) => current.filter((item) => item !== normalized)); + } + + function resetReportCurrencyOverride() { + setReportCurrencyOverride(''); + setCookie('mining_checker_report_currency', '', 0); + } + + const tabs = [ + ['overview', 'Ueberblick'], + ['measurements', 'Messpunkte'], + ['currencies', 'Waehrungen'], + ['mining', 'Mining'], + ['dashboards', 'Dashboards'], + ['settings', 'Settings'], + ]; + const selectedFxCodes = fxSelection.length ? fxSelection.map((code) => String(code || '').toUpperCase()) : ['DOGE', 'USD', 'EUR']; + const fxDisplayBaseNormalized = String(fxDisplayBase || 'USD').toUpperCase(); + const groupedFxHistoryMap = new Map(); + fxHistory.forEach((row, index) => { + const fetchId = row.fetch_id || `legacy-${row.fetched_at || 'none'}-${index}`; + if (!groupedFxHistoryMap.has(fetchId)) { + groupedFxHistoryMap.set(fetchId, { + fetch_id: fetchId, + fetched_at: row.fetched_at || null, + rate_date: row.rate_date || null, + base_currency: row.base_currency || null, + provider: row.provider || null, + rates: {}, + }); + } + + const group = groupedFxHistoryMap.get(fetchId); + group.rates[String(row.target_currency || '').toUpperCase()] = row.rate; + }); + + function computeDisplayedFxRate(group, targetCode, displayBaseCode) { + const normalizedTarget = String(targetCode || '').toUpperCase(); + const normalizedDisplayBase = String(displayBaseCode || '').toUpperCase(); + const fetchBase = String(group.base_currency || '').toUpperCase(); + + if (!normalizedTarget || !normalizedDisplayBase) { + return null; + } + if (normalizedTarget === normalizedDisplayBase) { + return 1; + } + + const targetRateRaw = normalizedTarget === fetchBase ? 1 : group.rates[normalizedTarget]; + const displayBaseRateRaw = normalizedDisplayBase === fetchBase ? 1 : group.rates[normalizedDisplayBase]; + const targetRate = targetRateRaw === null || targetRateRaw === undefined ? null : Number(targetRateRaw); + const displayBaseRate = displayBaseRateRaw === null || displayBaseRateRaw === undefined ? null : Number(displayBaseRateRaw); + + if (!Number.isFinite(targetRate) || !Number.isFinite(displayBaseRate) || displayBaseRate === 0) { + return null; + } + + return targetRate / displayBaseRate; + } + + const groupedFxHistory = Array.from(groupedFxHistoryMap.values()) + .map((group) => ({ + ...group, + selected_rates: selectedFxCodes.map((code) => ({ + code, + rate: computeDisplayedFxRate(group, code, fxDisplayBaseNormalized), + })), + })) + .filter((group) => group.selected_rates.some((item) => item.rate !== null)) + .slice(0, 30); + const debugConsoleText = debugEntries + .map((entry) => `${entry.time} · ${entry.type}\n${JSON.stringify(entry, null, 2)}`) + .join('\n\n'); + const moduleThemeMode = ['inherit', 'custom'].includes(String(currentSettings.module_theme_mode || '')) + ? String(currentSettings.module_theme_mode) + : 'inherit'; + const moduleThemeAccent = ['teal', 'logo', 'pink', 'cyan', 'orange', 'green'].includes(String(currentSettings.module_theme_accent || '')) + ? String(currentSettings.module_theme_accent) + : 'teal'; + + return h('div', { + className: 'mc-grid-bg', + 'data-module-theme': moduleThemeMode, + 'data-module-accent': moduleThemeAccent, + }, [ + h('div', { key: 'shell', className: 'mc-shell mc-stack' }, [ + h('header', { key: 'header', className: 'mc-hero' }, [ + h('div', { key: 'top', className: 'mc-hero-top' }, [ + h('div', { key: 'copy', className: 'mc-hero-copy' }, [ + h(Badge, { key: 'eyebrow' }, 'Mining-Checker Modul'), + h('h1', { key: 'title', className: 'mc-title' }, 'DOGE Mining-Checker'), + h('p', { key: 'lead', className: 'mc-text' }, + 'Messpunkte, Miner, Ziele, Waehrungen und Auswertungen in einer Oberflaeche.'), + ]), + h('div', { key: 'controls', className: 'mc-hero-controls' }, [ + h('a', { + key: 'home', + href: '/', + className: 'mc-button mc-button--ghost mc-home-link', + }, 'Zur Startseite'), + h('div', { key: 'project', className: 'mc-form-card' }, [ + h('div', { key: 'label', className: 'mc-field-label' }, 'Project Key'), + h('div', { key: 'value', className: 'mc-text' }, projectKey), + ]), + ]), + ]), + h('div', { key: 'tabs', className: 'mc-tabs' }, + tabs.map((tab) => h('button', { + key: tab[0], + type: 'button', + className: cx( + 'mc-button', + activeTab === tab[0] + ? 'mc-button--tab-active' + : 'mc-button--tab' + ), + onClick: () => setActiveTab(tab[0]), + }, tab[1])) + ), + ]), + error ? h('div', { key: 'error', className: 'mc-alert mc-alert--error' }, error) : null, + message ? h('div', { key: 'message', className: 'mc-alert mc-alert--success' }, message) : null, + loading ? h('div', { key: 'loading', className: 'mc-empty' }, 'Lade Mining-Checker Daten …') : null, + payload ? renderTab() : null, + h('div', { key: 'debug-tools', className: 'mc-debug-tools' }, [ + h('button', { + key: 'debug-toggle', + type: 'button', + className: cx('mc-button', debugEnabled ? 'mc-button--secondary' : 'mc-button--ghost'), + onClick: () => { + const next = !debugEnabled; + setDebugEnabled(next); + if (next) { + setDebugConsoleOpen(true); + } + }, + }, debugEnabled ? 'Debug aktiv' : 'Debug einschalten'), + h('button', { + key: 'console-toggle', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => setDebugConsoleOpen((current) => { + const next = !current; + if (next) { + setDebugEnabled(true); + } + return next; + }), + }, debugConsoleOpen ? 'Online-Konsole ausblenden' : 'Online-Konsole einblenden'), + debugEntries.length ? h('button', { + key: 'debug-clear', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => setDebugEntries([]), + }, 'Konsole leeren') : null, + debugEntries.length ? h('button', { + key: 'debug-copy', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: copyDebugConsole, + }, 'Copy content') : null, + ]), + debugConsoleOpen ? h('section', { key: 'debug-console', className: 'mc-panel mc-debug-console' }, [ + h(SectionTitle, { + key: 'debug-title', + title: 'Online-Konsole', + subtitle: 'Zeigt API-, Provider- und DB-Debugspuren direkt aus dem Modul.', + action: h(Badge, { tone: debugEnabled ? 'success' : 'warn' }, debugEnabled ? 'Debug aktiv' : 'Debug aus'), + }), + h('div', { key: 'debug-body', className: 'mc-panel-body' }, [ + h('div', { key: 'debug-view-switch', className: 'mc-debug-view-switch' }, [ + h('button', { + key: 'view-structured', + type: 'button', + className: cx('mc-button', debugView === 'structured' ? 'mc-button--tab-active' : 'mc-button--tab'), + onClick: () => setDebugView('structured'), + }, 'Strukturiert'), + h('button', { + key: 'view-text', + type: 'button', + className: cx('mc-button', debugView === 'text' ? 'mc-button--tab-active' : 'mc-button--tab'), + onClick: () => setDebugView('text'), + }, 'Text-Konsole'), + ]), + debugView === 'text' + ? h('pre', { className: 'mc-code-block mc-debug-text-console' }, debugConsoleText || 'Noch keine Debug-Ausgaben vorhanden.') + : h('div', { className: 'mc-debug-log' }, + debugEntries.length + ? debugEntries.map((entry) => h('div', { key: entry.id, className: 'mc-debug-entry' }, [ + h('div', { key: 'meta', className: 'mc-kicker' }, `${entry.time} · ${entry.type}`), + h('pre', { key: 'payload', className: 'mc-code-block' }, JSON.stringify(entry, null, 2)), + ])) + : h('div', { className: 'mc-empty' }, 'Noch keine Debug-Ausgaben vorhanden.') + ), + ]), + ]) : null, + ]), + ]); + + function renderTab() { + if (activeTab === 'overview') { + const latestValue = latest ? convertMeasurementMoney(latest, latest.current_value, reportCurrency) : null; + const latestPriceSource = latest && latest.effective_price_per_coin !== null && latest.effective_price_per_coin !== undefined + ? latest.effective_price_per_coin + : (latest ? latest.price_per_coin : null); + const latestPrice = latest && latestPriceSource !== null && latestPriceSource !== undefined + ? convertMeasurementMoney(latest, latestPriceSource, reportCurrency) + : null; + const dailyRevenue = latest ? convertMeasurementMoney(latest, latest.theoretical_daily_revenue, reportCurrency) : null; + const dailyProfit = latest ? convertMeasurementMoney(latest, latest.theoretical_daily_profit, reportCurrency) : null; + const dailyCost = latest ? convertMeasurementMoney(latest, latest.effective_daily_cost, reportCurrency) : null; + const breakEvenPrice = latest && latest.break_even_price_per_coin !== null && latest.break_even_price_per_coin !== undefined + ? convertMeasurementMoney(latest, latest.break_even_price_per_coin, reportCurrency) + : null; + const breakEvenRemainingAmount = latest ? convertMeasurementMoney(latest, latest.break_even_remaining_amount, reportCurrency) : null; + const breakEvenDaysOverall = latest && latest.break_even_days_overall !== null && latest.break_even_days_overall !== undefined + ? Number(latest.break_even_days_overall) + : null; + const investedCapital = latest ? convertMeasurementMoney(latest, latest.invested_capital, reportCurrency) : null; + const breakEvenReached = breakEvenRemainingAmount !== null && breakEvenRemainingAmount <= 0; + const breakEvenDate = (() => { + if (!latest || breakEvenDaysOverall === null || !Number.isFinite(breakEvenDaysOverall)) { + return null; + } + const baseTimestamp = Date.parse(String(latest.measured_at || '')); + if (!Number.isFinite(baseTimestamp)) { + return null; + } + return new Date(baseTimestamp + (breakEvenDaysOverall * 24 * 60 * 60 * 1000)); + })(); + + return h('div', { className: 'mc-stack' }, [ + panel('Berichtswährung', 'Bestimmt die Währung für Kennzahlen im Überblick. Standard kommt aus den Settings, diese Auswahl gilt nur für den aktuellen Besuch.', [ + h('div', { className: 'mc-inline-fields' }, [ + selectField( + 'Aktueller Besuch', + reportCurrency, + selectableCurrencies.map((currency) => currency.code), + (value) => setReportCurrencyOverride(String(value || '').toUpperCase()) + ), + h('button', { + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: resetReportCurrencyOverride, + disabled: !reportCurrencyOverride, + }, 'Standard verwenden'), + ]), + ]), + h('div', { key: 'stats', className: 'mc-stats-grid' }, [ + h(StatCard, { + key: 'coins', + label: 'Coins sichtbar', + value: latest ? fmtNumber(latest.coins_total_visible || latest.coins_total, 6) : 'n/a', + sub: latest ? `Stand ${fmtDate(latest.measured_at)}` : '', + }), + h(StatCard, { + key: 'coins-effective', + label: 'Coins effektiv', + value: payload?.summary?.payouts ? fmtNumber(payload.summary.payouts.current_effective_coins, 6) : 'n/a', + sub: payload?.summary?.payouts ? `Ausgezahlt ${fmtNumber(payload.summary.payouts.total_coins, 6)} DOGE` : '', + }), + h(StatCard, { + key: 'perday', + label: 'DOGE pro Tag', + 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: 'value', + label: 'Aktueller Gegenwert', + value: latestValue !== null ? fmtMoney(latestValue, reportCurrency) : 'n/a', + sub: latestPrice !== null + ? `Kurs ${fmtNumber(latestPrice, 6)} ${reportCurrency}${latest && latest.price_is_fallback ? ' · Fallback aus letztem Kurs' : ''}` + : 'Kein umrechenbarer Kurs am letzten Punkt', + }), + h(StatCard, { + key: 'profit', + label: 'Theoretischer Tagesgewinn', + value: dailyProfit !== null ? fmtMoney(dailyProfit, reportCurrency) : 'n/a', + sub: dailyCost !== null + ? `Tageskosten ${fmtMoney(dailyCost, reportCurrency)} · Break-even ${breakEvenPrice !== null ? `${fmtNumber(breakEvenPrice, 6)} ${reportCurrency}` : 'n/a'}` + : 'Kein aktiver Miner fuer diese Waehrung', + }), + h(StatCard, { + key: 'break-even-point', + label: 'Break-even', + value: breakEvenDaysOverall !== null + ? `${fmtNumber(breakEvenDaysOverall, 2)} Tage` + : (breakEvenReached ? 'Erreicht' : (investedCapital === null ? 'Keine Mietbasis' : 'n/a')), + sub: investedCapital !== null + ? `${breakEvenDate ? `Theoretisch ${fmtDate(breakEvenDate.toISOString())} · ` : ''}Basis ${fmtMoney(investedCapital, reportCurrency)}${dailyRevenue !== null ? ` · Tagesumsatz ${fmtMoney(dailyRevenue, reportCurrency)}` : ''}` + : (breakEvenPrice !== null + ? `Break-even-Kurs ${fmtNumber(breakEvenPrice, 6)} ${reportCurrency}` + : (investedCapital === null + ? 'Noch keine Miner als Mietbasis hinterlegt' + : 'Keine belastbare Break-even-Basis')), + }), + ]), + h('div', { key: 'charts', className: 'mc-overview-grid' }, [ + panel('Mining-Verlauf', 'Coins total ueber die Zeit.', h(SimpleChart, { type: 'line', data: overviewCharts.mining })), + panel('Performance-Verlauf', 'Letzte DOGE-pro-Tag-Raten je Intervall.', h(SimpleChart, { type: 'area', data: overviewCharts.performance })), + panel('Kurs-Verlauf', 'Historische Preiswerte der Messreihe.', h(SimpleChart, { type: 'line', data: overviewCharts.pricing })), + ]), + panel('Zielmonitor', 'Rest-DOGE und Resttage werden gegen den letzten verfuegbaren Kurs je Zielwaehrung berechnet.', + h('div', { className: 'mc-target-grid' }, + currentTargets.map((target, index) => h('div', { + key: index, + className: 'mc-target-card' + }, [ + h('div', { key: 'head', className: 'mc-flex-split' }, [ + h('h3', { key: 'title' }, target.label), + h(Badge, { key: 'status', tone: target.status === 'reached' ? 'success' : 'info' }, target.status), + ]), + h('div', { key: 'body', className: 'mc-text mc-target-grid' }, [ + h('div', { key: 'amount' }, `Ziel: ${fmtMoney(target.target_amount_fiat, target.currency)}`), + h('div', { key: 'price' }, `Letzter Kurs: ${target.latest_price_for_currency ? fmtNumber(target.latest_price_for_currency, 6) + ' ' + target.currency : 'n/a'}`), + h('div', { key: 'doge' }, `Benoetigte DOGE: ${fmtNumber(target.required_doge, 6)}`), + h('div', { key: 'remaining' }, `Rest-DOGE: ${fmtNumber(target.remaining_doge, 6)}`), + h('div', { key: 'days' }, `Resttage: ${fmtNumber(target.remaining_days, 4)}`), + ]), + ])) + ) + ), + ]); + } + + if (activeTab === 'measurements') { + return h('div', { className: 'mc-main-grid' }, [ + h('div', { className: 'mc-stack' }, [ + (function () { + const preview = normalizeOcrPreview(ocrPreview); + const hasUsableOcrSuggestion = preview.suggested.coins_total !== '' && preview.suggested.coins_total !== null; + const ocrStatus = getOcrStatusMessage(preview); + return panel('OCR Upload', 'Screenshot auswaehlen, Ergebnis direkt pruefen und speichern.', [ + h('div', { + key: 'ocr-form', + className: 'mc-form', + }, [ + fileField('Screenshot', (file) => loadOcrPreview(file, { image: file })), + ]), + saving && ocrForm.image + ? h('div', { key: 'ocr-loading', className: 'mc-empty' }, 'Analysiere Screenshot …') + : null, + ocrPreview + ? h('div', { key: 'ocr-preview', className: 'mc-form' }, [ + h('div', { key: 'badges', className: 'mc-inline-row' }, [ + h(Badge, { key: 'confidence', tone: preview.confidence >= 0.75 ? 'success' : 'warn' }, `confidence ${fmtNumber(preview.confidence, 4)}`), + ]), + h('div', { key: 'form', className: 'mc-two-col' }, [ + displayField('Datum/Zeit', 'Wird beim Speichern auf den aktuellen Bestätigungszeitpunkt gesetzt.'), + displayField('Coins total', fmtNumber(preview.suggested.coins_total, 6)), + displayField('Kurs', fmtNumber(preview.suggested.price_per_coin, 6)), + displayField('Waehrung', preview.suggested.price_currency || 'n/a'), + ]), + ocrStatus + ? h('div', { + key: 'ocr-status', + className: cx( + 'mc-alert', + ocrStatus.tone === 'error' ? 'mc-alert--error' : 'mc-alert--warning' + ), + }, ocrStatus.text) + : null, + !hasUsableOcrSuggestion + ? h('div', { key: 'ocr-warning', className: 'mc-alert mc-alert--error' }, + 'Kein verwertbarer OCR-Vorschlag erkannt. Bitte Bild erneut hochladen oder den Messpunkt manuell erfassen.') + : null, + h('button', { + key: 'confirm', + type: 'button', + className: 'mc-button mc-button--primary', + onClick: () => submitMeasurement(true), + disabled: saving || !hasUsableOcrSuggestion, + }, saving ? 'Speichert …' : 'Ergebnis speichern'), + ]) + : h('div', { key: 'ocr-empty', className: 'mc-empty' }, + 'Noch kein Screenshot ausgewaehlt.'), + ]); + })(), + panel('Messpunkt manuell erfassen', 'Direkte Eingabe eines einzelnen Messpunkts mit serverseitiger Validierung.', h('form', { + className: 'mc-form', + onSubmit: function (event) { + event.preventDefault(); + submitMeasurement(false); + }, + }, [ + displayField('Zeitpunkt', 'Wird beim Speichern automatisch auf den aktuellen Bestätigungszeitpunkt gesetzt.'), + inputField('Coins total', 'number', measurementForm.coins_total, (value) => setMeasurementForm({ ...measurementForm, coins_total: value }), '0.000001'), + inputField('Kurs', 'number', measurementForm.price_per_coin, (value) => setMeasurementForm({ ...measurementForm, price_per_coin: value }), '0.000001'), + selectField('Waehrung', measurementForm.price_currency, [''].concat(selectableCurrencies.map((currency) => currency.code)), (value) => setMeasurementForm({ ...measurementForm, price_currency: value })), + textareaField('Notiz', measurementForm.note, (value) => setMeasurementForm({ ...measurementForm, note: value })), + h('button', { + type: 'submit', + className: 'mc-button mc-button--secondary', + disabled: saving, + }, saving ? 'Speichert …' : 'Messpunkt speichern'), + ])), + panel('Import per Copy & Paste', 'Mehrere historische Messpunkte auf einmal einfuegen. Doppelte Eintraege werden ignoriert.', h('form', { + className: 'mc-form', + onSubmit: submitMeasurementImport, + }, [ + h('div', { className: 'mc-inline-row' }, [ + h('button', { + key: 'help', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => setImportHelpOpen(true), + }, 'Import-Hilfe'), + ]), + displayField('Format', 'DD.MM.YYYY HH:MM | Coins | Kurs | Waehrung | Notiz'), + textareaField('Importdaten', importForm.rows_text, (value) => setImportForm({ ...importForm, rows_text: value })), + selectField('Standard-Waehrung', importForm.default_currency, [''].concat(selectableCurrencies.map((currency) => currency.code)), (value) => setImportForm({ ...importForm, default_currency: value })), + selectField('Import-Quelle', importForm.source, ['manual', 'seed_import'], (value) => setImportForm({ ...importForm, source: value })), + h('button', { + type: 'submit', + className: 'mc-button mc-button--secondary', + disabled: saving, + }, saving ? 'Importiert …' : 'Import ausfuehren'), + ])), + importHelpOpen ? h('div', { className: 'mc-modal-backdrop', onClick: () => setImportHelpOpen(false) }, [ + h('div', { + key: 'modal', + className: 'mc-modal', + onClick: (event) => event.stopPropagation(), + }, [ + h('div', { key: 'head', className: 'mc-flex-split' }, [ + h('h3', { key: 'title' }, 'Import-Hilfe'), + h('button', { + key: 'close', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => setImportHelpOpen(false), + }, 'Schliessen'), + ]), + h('div', { key: 'body', className: 'mc-form' }, [ + displayField('Format', 'DD.MM.YYYY HH:MM | Coins | Kurs | Waehrung | Notiz'), + h('div', { key: 'rules', className: 'mc-display-field' }, [ + h('div', { key: 'rules-label', className: 'mc-field-label' }, 'Hinweise'), + h('div', { key: 'rules-text', className: 'mc-text' }, [ + 'Leere Zeilen sind erlaubt. ', + 'Zeilen mit # oder // am Anfang werden ignoriert. ', + 'Wenn ein Kurs gesetzt ist, muss auch eine Waehrung gesetzt sein. ', + 'Duplikate werden automatisch ignoriert.' + ]), + ]), + h('div', { key: 'example-wrap', className: 'mc-display-field' }, [ + h('div', { key: 'example-label', className: 'mc-field-label' }, 'Beispiel'), + h('pre', { key: 'example', className: 'mc-code-block' }, [ + '21.03.2026 23:48 | 50.988525 | 0.09316 | USD | Screenshot importiert\n', + '22.03.2026 08:10 | 51.402100 | 0.09420 | USD | Morgens\n', + '22.03.2026 14:30 | 51.998700 | | | ohne Kurs' + ]), + ]), + ]), + ]), + ]) : null, + ]), + panel('Messhistorie', 'Alle Messpunkte 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', 'DOGE/Tag', 'Trend', 'Notiz' + ].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'tbody' }, + measurements.slice().reverse().map((row) => h('tr', { key: row.id }, [ + h('td', { key: 'measured' }, fmtDate(row.measured_at)), + h('td', { key: 'coins' }, fmtNumber(row.coins_total, 6)), + 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: 'trend' }, row.trend_label), + h('td', { key: 'note' }, row.note || row.ocr_flags.join(', ') || '—'), + ])) + ), + ]), + ])), + ]); + } + + if (activeTab === 'dashboards') { + return h('div', { className: 'mc-main-grid' }, [ + panel('Dashboard-Builder V1', 'Chart-Typ, X/Y-Feld, Aggregation und einfache Filter werden gespeichert.', h('form', { + className: 'mc-form', + onSubmit: submitDashboard, + }, [ + inputField('Name', 'text', dashboardForm.name, (value) => setDashboardForm({ ...dashboardForm, name: value })), + selectField('Chart-Typ', dashboardForm.chart_type, ['line', 'bar', 'area', 'table'], (value) => setDashboardForm({ ...dashboardForm, chart_type: value })), + selectField('X-Feld', dashboardForm.x_field, ['measured_at', 'measured_date', 'source', 'price_currency', 'trend_label'], (value) => setDashboardForm({ ...dashboardForm, x_field: value })), + selectField('Y-Feld', dashboardForm.y_field, ['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'], (value) => setDashboardForm({ ...dashboardForm, y_field: value })), + selectField('Aggregation', dashboardForm.aggregation, ['none', 'sum', 'avg', 'min', 'max', 'count', 'latest'], (value) => setDashboardForm({ ...dashboardForm, aggregation: value })), + selectField('Filter Quelle', dashboardForm.filters.source, ['', 'manual', 'image_ocr', 'seed_import'], (value) => setDashboardForm({ ...dashboardForm, filters: { ...dashboardForm.filters, source: value } })), + selectField('Filter Waehrung', dashboardForm.filters.currency, [''].concat(selectableCurrencies.map((currency) => currency.code)), (value) => setDashboardForm({ ...dashboardForm, filters: { ...dashboardForm.filters, currency: value } })), + h('button', { + type: 'submit', + className: 'mc-button mc-button--secondary', + disabled: saving, + }, saving ? 'Speichert …' : 'Dashboard speichern'), + ])), + h('div', { className: 'mc-stack' }, currentDashboards.map((definition) => h(DashboardCard, { + key: definition.id, + definition, + data: dashboardData[definition.id], + loading: !dashboardData[definition.id], + }))), + ]); + } + + if (activeTab === 'currencies') { + const selectedSet = new Set((fxSelection || []).map((code) => String(code).toUpperCase())); + const searchNeedle = fxSearch.trim().toLowerCase(); + const currencySuggestions = currencies + .filter((currency) => !selectedSet.has(String(currency.code || '').toUpperCase())) + .filter((currency) => { + if (!searchNeedle) { + return false; + } + const code = String(currency.code || '').toLowerCase(); + const name = String(currency.name || '').toLowerCase(); + return code.includes(searchNeedle) || name.includes(searchNeedle); + }) + .slice(0, 12); + return h('div', { className: 'mc-stack' }, [ + h('div', { className: 'mc-stack' }, [ + panel('Waehrungs-Update', 'Auswahl wird in den Mining-Checker-Settings gespeichert und steht damit auf Handy und Desktop gleich zur Verfuegung.', [ + h('div', { key: 'actions', className: 'mc-inline-row' }, [ + h('button', { + key: 'save-selection', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: saveFxSelection, + disabled: saving, + }, saving ? 'Speichert …' : 'Auswahl speichern'), + h('button', { + key: 'refresh-rates', + type: 'button', + className: 'mc-button mc-button--primary', + onClick: refreshSelectedFxRates, + disabled: saving, + }, saving ? 'Aktualisiert …' : 'Alle Wechselkurse aktualisieren'), + h('button', { + key: 'refresh-catalog', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: refreshCurrencyCatalog, + disabled: saving, + }, saving ? 'Synchronisiert …' : 'Waehrungskatalog sync'), + ]), + h('div', { key: 'types', className: 'mc-mini-grid' }, [ + h('div', { key: 'fiat', className: 'mc-mini-card' }, [ + h('div', { key: 'fiat-label', className: 'mc-field-label' }, 'Fiat'), + h('div', { key: 'fiat-value' }, `${fiatCurrencies.length} Waehrungen`), + ]), + h('div', { key: 'crypto', className: 'mc-mini-card' }, [ + h('div', { key: 'crypto-label', className: 'mc-field-label' }, 'Krypto'), + h('div', { key: 'crypto-value' }, `${cryptoCurrencies.length} Waehrungen`), + ]), + ]), + h('div', { key: 'selection-title', className: 'mc-field-label' }, 'Bevorzugte Waehrungen fuer Anzeige'), + h('div', { key: 'selection-row', className: 'mc-currency-selection-row' }, [ + h('div', { key: 'selected', className: 'mc-token-list mc-token-list--inline' }, + fxSelection.length + ? fxSelection.map((code) => { + const currency = currencies.find((item) => item.code === code) || { code, name: code }; + return h('button', { + key: `token-${code}`, + type: 'button', + className: 'mc-token', + onClick: () => removeFxSelection(code), + title: `${code} entfernen`, + }, [ + h('span', { key: 'label' }, `${code} (${currency.name || code})`), + h('span', { key: 'close', className: 'mc-token-close' }, 'x'), + ]); + }) + : [h('div', { key: 'empty', className: 'mc-text' }, 'Noch keine bevorzugten Waehrungen ausgewaehlt.')] + ), + h('div', { key: 'search-wrap', className: 'mc-field mc-currency-search' }, [ + h('input', { + key: 'search-input', + type: 'text', + value: fxSearch, + className: 'mc-input', + placeholder: 'Waehrung hinzufuegen: EUR, USD, DOGE oder Euro', + onInput: (event) => setFxSearch(event.target.value), + }), + ]), + ]), + h('div', { key: 'display-base-wrap', className: 'mc-field mc-currency-search' }, [ + h('label', { key: 'display-base-label', className: 'mc-field-label' }, 'Darstellung auf Basis von'), + h('select', { + key: 'display-base', + className: 'mc-select', + value: selectedFxCodes.includes(fxDisplayBaseNormalized) ? fxDisplayBaseNormalized : (selectedFxCodes[0] || 'USD'), + onChange: (event) => setFxDisplayBase(event.target.value), + }, selectedFxCodes.map((code) => h('option', { key: code, value: code }, code))), + ]), + searchNeedle + ? h('div', { key: 'suggestions', className: 'mc-suggestion-list' }, + currencySuggestions.length + ? currencySuggestions.map((currency) => h('button', { + key: `suggestion-${currency.code}`, + type: 'button', + className: 'mc-suggestion', + onClick: () => addFxSelection(currency.code), + }, [ + h('strong', { key: 'code' }, currency.code), + h('span', { key: 'name' }, currency.name || currency.code), + ])) + : [h('div', { key: 'no-match', className: 'mc-text' }, 'Keine passende Waehrung gefunden.')] + ) + : null, + ]), + ]), + h('div', { className: 'mc-stack' }, [ + panel('Letzte 30 Kurs-Uploads', 'Zeigt die zuletzt gespeicherten Wechselkurse aus der Datenbank.', [ + h('div', { key: 'history-table', className: 'mc-table-shell' }, [ + h('table', { key: 'table', className: 'mc-table' }, [ + h('thead', { key: 'head' }, h('tr', null, ['Zeit', 'Stichtag', 'Fetch-Basis'].concat(selectedFxCodes).concat(['Provider']).map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'body' }, + groupedFxHistory.length + ? groupedFxHistory.map((row, index) => h('tr', { key: `${row.fetch_id || index}-${row.base_currency}` }, [ + h('td', { key: 'fetched' }, fmtDate(row.fetched_at)), + h('td', { key: 'date' }, row.rate_date || 'n/a'), + h('td', { key: 'base' }, row.base_currency), + ].concat( + row.selected_rates.map((item) => h('td', { key: `rate-${item.code}` }, item.rate === null ? 'n/a' : fmtNumber(item.rate, 8))) + ).concat([ + h('td', { key: 'provider' }, row.provider || 'n/a'), + ]))) + : [h('tr', { key: 'empty' }, h('td', { colSpan: 4 + selectedFxCodes.length }, 'Noch keine Wechselkurse fuer die ausgewaehlten Waehrungen gespeichert.'))] + ), + ]), + ]), + ]), + ]), + ]); + } + + if (activeTab === 'mining') { + const scenarioCurrency = selectedMinerScenario?.scenario_currency || reportCurrency; + const scenarioCurrentDailyProfit = selectedMinerScenario ? convertMeasurementMoney(latest, selectedMinerScenario.scenario_current_daily_profit, reportCurrency) : null; + const scenarioDailyProfit = selectedMinerScenario ? convertMeasurementMoney(latest, selectedMinerScenario.scenario_daily_profit, reportCurrency) : null; + const scenarioDailyProfitDelta = selectedMinerScenario ? convertMeasurementMoney(latest, selectedMinerScenario.scenario_daily_profit_delta, reportCurrency) : null; + const scenarioInvestedCapital = selectedMinerScenario ? convertMeasurementMoney(latest, selectedMinerScenario.scenario_invested_capital, reportCurrency) : null; + const scenarioOfferCost = selectedMinerScenario ? convertMeasurementMoney(latest, selectedMinerScenario.scenario_offer_cost, reportCurrency) : null; + const scenarioBreakEvenRemaining = selectedMinerScenario ? convertMeasurementMoney(latest, selectedMinerScenario.scenario_break_even_remaining_amount, reportCurrency) : null; + return h('div', { className: 'mc-stack' }, [ + panel('Aktive Miner', 'Alle bereits gemieteten oder manuell eingetragenen Miner in einer gemeinsamen Liste.', [ + h('div', { key: 'actions', className: 'mc-inline-row' }, [ + h('button', { + key: 'add-server', + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: () => setCostPlanModalOpen(true), + }, 'Miner eintragen'), + h('button', { + key: 'rent-miner', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => setPurchaseMinerModalOpen(true), + disabled: !availableMinerOffers.length, + }, 'Neuen Miner mieten'), + ]), + h('div', { key: 'list', className: 'mc-table-shell' }, [ + h('table', { key: 'table', className: 'mc-table' }, [ + h('thead', { key: 'head' }, h('tr', null, ['Label', 'Start', 'Laufzeit', 'Auto', 'Kosten', 'Waehrung', 'Aktiv', 'Aktion'].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'body' }, + activeMinerRows.length + ? activeMinerRows.map((row) => h('tr', { key: row.id }, [ + h('td', { key: 'label' }, [ + h('div', { key: 'main' }, row.label), + h('div', { key: 'type', className: 'mc-kicker' }, row.type_label), + ]), + h('td', { key: 'start' }, fmtDateTime(row.starts_at)), + h('td', { key: 'runtime' }, [ + h('div', { key: 'months' }, `${row.runtime_months} Monate`), + h('div', { key: 'hash', className: 'mc-kicker' }, row.hashrate_text), + ]), + h('td', { key: 'renew' }, row.auto_renew ? 'ja' : 'nein'), + h('td', { key: 'cost' }, [ + h('div', { key: 'effective' }, fmtNumber(row.effective_amount, 6)), + row.base_amount !== null && row.base_amount !== undefined && row.base_currency + ? h('div', { key: 'base', className: 'mc-kicker' }, `Basis ${fmtNumber(row.base_amount, 6)} ${row.base_currency}`) + : null, + ]), + h('td', { key: 'currency' }, [ + h('div', { key: 'currency-main' }, row.effective_currency), + row.payment_type ? h('div', { key: 'currency-mode', className: 'mc-kicker' }, row.payment_type === 'crypto' ? 'Zahlung Krypto' : 'Zahlung FIAT') : null, + ]), + h('td', { key: 'active' }, row.is_active ? 'ja' : 'nein'), + h('td', { key: 'action' }, + row.can_toggle_auto_renew + ? h('button', { + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => togglePurchasedMinerAutoRenew(row), + disabled: saving, + }, row.auto_renew ? 'Verlaengerung aus' : 'Verlaengerung an') + : '—' + ), + ])) + : [h('tr', { key: 'empty' }, h('td', { colSpan: 8 }, 'Noch keine Miner hinterlegt.'))] + ), + ]), + ]), + ]), + panel('Auszahlungen', 'Auszahlungen reduzieren den sichtbaren Coin-Bestand, bleiben aber in der Gesamtleistung erhalten.', [ + h('div', { key: 'actions', className: 'mc-inline-row' }, [ + h('button', { + key: 'add-payout', + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: () => setPayoutModalOpen(true), + }, 'Auszahlung erfassen'), + ]), + h('div', { key: 'payout-list', className: 'mc-table-shell' }, [ + h('table', { key: 'payout-table', className: 'mc-table' }, [ + h('thead', { key: 'head' }, h('tr', null, ['Zeit', 'Coins', 'Waehrung', 'Notiz'].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'body' }, + currentPayouts.length + ? currentPayouts.slice().reverse().map((payout) => h('tr', { key: payout.id }, [ + h('td', { key: 'time' }, fmtDate(payout.payout_at)), + h('td', { key: 'coins' }, fmtNumber(payout.coins_amount, 6)), + h('td', { key: 'currency' }, payout.payout_currency), + h('td', { key: 'note' }, payout.note || '—'), + ])) + : [h('tr', { key: 'empty' }, h('td', { colSpan: 4 }, 'Noch keine Auszahlungen hinterlegt.'))] + ), + ]), + ]), + ]), + panel('Miner-Angebote', 'Angebote fuer neue Miner und eine grobe Reinvestitionsbewertung auf Basis der aktuellen Leistung.', [ + h('div', { key: 'actions', className: 'mc-inline-row' }, [ + h('button', { + key: 'add-offer', + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: () => setMinerOfferModalOpen(true), + }, 'Miner-Angebot anlegen'), + ]), + h('div', { key: 'filters', className: 'mc-filter-grid' }, [ + inputField(`Min. Geschwindigkeit (${minerOfferFilters.speed_unit === 'kh' ? 'kH/s' : 'MH/s'})`, 'number', minerOfferFilters.speed_min, (value) => setMinerOfferFilters({ ...minerOfferFilters, speed_min: value }), '0.0001'), + selectField('Geschwindigkeitseinheit', minerOfferFilters.speed_unit, [ + { value: 'auto', label: 'MH/s' }, + { value: 'kh', label: 'kH/s' }, + ], (value) => setMinerOfferFilters({ ...minerOfferFilters, speed_unit: value || 'auto' })), + inputField(`Max. Basispreis (${reportCurrency})`, 'number', minerOfferFilters.price_max, (value) => setMinerOfferFilters({ ...minerOfferFilters, price_max: value }), '0.0001'), + selectField('Laufzeit', minerOfferFilters.runtime_months, [{ value: '', label: 'Alle Laufzeiten' }].concat(Array.from(new Set(availableMinerOffers.map((offer) => String(offer.runtime_months || '')).filter(Boolean))).sort((a, b) => Number(a) - Number(b)).map((value) => ({ + value, + label: `${value} Monate`, + }))), (value) => setMinerOfferFilters({ ...minerOfferFilters, runtime_months: value })), + ]), + h('div', { key: 'offers-table', className: 'mc-table-shell' }, [ + h('table', { key: 'table', className: 'mc-table' }, [ + h('thead', { key: 'head' }, h('tr', null, ['Label', 'Hashrate', 'Preis', 'Erwartet/Tag', 'Break-even', 'Empfehlung', 'Aktion'].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'body' }, + filteredMinerOffers.length + ? filteredMinerOffers.map((offer) => h('tr', { key: offer.id }, [ + h('td', { key: 'label' }, offer.label), + h('td', { key: 'hashrate' }, formatAdaptiveSpeed(offer.offer_hashrate_mh)), + h('td', { key: 'price' }, [ + h('div', { key: 'price-main' }, [ + h('div', { key: 'amount' }, `${fmtNumber(offer.effective_price_amount, 6)} ${offer.effective_price_currency}`), + h('div', { key: 'label', className: 'mc-kicker' }, 'Zu zahlen'), + ]), + offer.base_price_amount !== null && offer.base_price_currency + ? h('div', { key: 'price-base' }, [ + h('div', { key: 'amount' }, `${fmtNumber(offer.base_price_amount, 6)} ${offer.base_price_currency}`), + h('div', { key: 'label', className: 'mc-kicker' }, 'Gegenwert'), + ]) + : null, + ]), + h('td', { key: 'day' }, offer.expected_doge_per_day !== null ? `${fmtNumber(offer.expected_doge_per_day, 6)} DOGE` : 'n/a'), + h('td', { key: 'break' }, offer.break_even_days !== null ? `${fmtNumber(offer.break_even_days, 2)} Tage` : 'n/a'), + h('td', { key: 'rec' }, [ + h('div', { key: 'rec-main' }, offer.recommendation), + offer.base_price_amount !== null && offer.base_price_currency && offer.payment_type !== 'crypto' + ? h('div', { key: 'rec-ref', className: 'mc-kicker' }, `Basis ${fmtNumber(offer.base_price_amount, 6)} ${offer.base_price_currency}`) + : null, + offer.payment_type ? h('div', { key: 'paytype', className: 'mc-kicker' }, offer.payment_type === 'crypto' ? `Zahlung in Krypto (${currentSettings.crypto_currency || 'DOGE'})` : `Zahlung in FIAT (${offer.base_price_currency || 'EUR'})`) : null, + h('div', { key: 'renew', className: 'mc-kicker' }, offer.auto_renew ? 'Automatische Verlängerung' : 'Laeuft aus'), + ]), + h('td', { key: 'action' }, [ + h('button', { + key: 'scenario', + type: 'button', + className: cx('mc-button', Number(selectedMinerScenario?.id) === Number(offer.id) ? 'mc-button--secondary' : 'mc-button--ghost'), + onClick: () => setSelectedMinerScenarioId(offer.id), + }, 'Szenario'), + h('button', { + key: 'target', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => { + setTargetForm({ + label: offer.label, + target_amount_fiat: String(offer.base_price_amount ?? offer.effective_price_amount ?? ''), + currency: offer.base_price_currency || offer.effective_price_currency || 'EUR', + miner_offer_id: String(offer.id), + is_active: true, + sort_order: 0, + }); + setTargetModalOpen(true); + }, + }, 'Als Ziel'), + h('button', { + key: 'buy', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => { + setPurchaseMinerForm({ + offer_id: String(offer.id), + purchased_at: new Date().toISOString().slice(0, 16), + total_cost_amount: offer.effective_price_amount !== null && offer.effective_price_amount !== undefined ? String(offer.effective_price_amount) : '', + currency: offer.effective_price_currency || offer.base_price_currency || 'USD', + reference_price_amount: offer.base_price_amount !== null && offer.base_price_amount !== undefined ? String(offer.base_price_amount) : '', + reference_price_currency: offer.base_price_currency || '', + auto_renew: !!offer.auto_renew, + note: '', + }); + setPurchaseMinerModalOpen(true); + }, + }, 'Mieten'), + ]), + ])) + : [h('tr', { key: 'empty' }, h('td', { colSpan: 7 }, 'Keine Angebote passen auf die gesetzten Filter.'))] + ), + ]), + ]), + selectedMinerScenario ? panel( + `Szenario: ${selectedMinerScenario.label}`, + 'Zeigt, wie sich Kennzahlen veraendern wuerden, wenn dieser Miner jetzt zusaetzlich gemietet wird.', + [ + h('div', { key: 'scenario-stats', className: 'mc-stats-grid' }, [ + h(StatCard, { + key: 'scenario-profit', + label: 'Tagesgewinn Neu', + value: scenarioDailyProfit !== null ? fmtMoney(scenarioDailyProfit, reportCurrency) : 'n/a', + sub: scenarioDailyProfitDelta !== null + ? `Aenderung pro Tag ${fmtMoney(scenarioDailyProfitDelta, reportCurrency)}` + : 'Keine belastbare Gewinnprognose', + }), + h(StatCard, { + key: 'scenario-doge', + label: 'DOGE pro Tag Neu', + value: selectedMinerScenario.scenario_doge_per_day !== null ? fmtNumber(selectedMinerScenario.scenario_doge_per_day, 4) : 'n/a', + sub: selectedMinerScenario.scenario_current_doge_per_day !== null + ? `Aktuell ${fmtNumber(selectedMinerScenario.scenario_current_doge_per_day, 4)}` + : 'Keine aktuelle DOGE/Tag-Basis', + }), + h(StatCard, { + key: 'scenario-break-even', + label: 'Break-even Neu', + value: selectedMinerScenario.scenario_break_even_days !== null + ? `${fmtNumber(selectedMinerScenario.scenario_break_even_days, 2)} Tage` + : 'n/a', + sub: selectedMinerScenario.scenario_break_even_date + ? `Theoretisch ${fmtDate(selectedMinerScenario.scenario_break_even_date)}` + : 'Kein belastbares Break-even-Datum', + }), + h(StatCard, { + key: 'scenario-capital', + label: 'Kosten inkl. Miete', + value: scenarioInvestedCapital !== null ? fmtMoney(scenarioInvestedCapital, reportCurrency) : 'n/a', + sub: scenarioOfferCost !== null + ? `Neue Miete ${fmtMoney(scenarioOfferCost, reportCurrency)}` + : `Mietpreis in ${scenarioCurrency}`, + }), + h(StatCard, { + key: 'scenario-two-year', + label: '2 Jahre Ergebnis Neu', + value: selectedMinerScenario.scenario_two_year_profit !== null + ? fmtMoney(convertMeasurementMoney(latest, selectedMinerScenario.scenario_two_year_profit, reportCurrency), reportCurrency) + : 'n/a', + sub: selectedMinerScenario.scenario_two_year_profit_delta !== null + ? `Aenderung ggü. heute ${fmtMoney(convertMeasurementMoney(latest, selectedMinerScenario.scenario_two_year_profit_delta, reportCurrency), reportCurrency)}` + : 'Laufzeit und Verlaengerung beruecksichtigt', + }), + ]), + h('div', { key: 'scenario-meta', className: 'mc-mini-grid' }, [ + h('div', { key: 'hashrate', className: 'mc-mini-card' }, [ + h('div', { key: 'label', className: 'mc-field-label' }, 'Hashrate'), + h('div', { key: 'value' }, selectedMinerScenario.scenario_hashrate_mh !== null ? `${fmtNumber(selectedMinerScenario.scenario_hashrate_mh, 4)} MH/s` : 'n/a'), + h('div', { key: 'sub', className: 'mc-kicker' }, selectedMinerScenario.scenario_current_hashrate_mh !== null ? `Aktuell ${fmtNumber(selectedMinerScenario.scenario_current_hashrate_mh, 4)} MH/s` : 'Aktuell n/a'), + ]), + h('div', { key: 'remaining', className: 'mc-mini-card' }, [ + h('div', { key: 'label', className: 'mc-field-label' }, 'Offen bis Break-even'), + h('div', { key: 'value' }, scenarioBreakEvenRemaining !== null ? fmtMoney(scenarioBreakEvenRemaining, reportCurrency) : 'n/a'), + h('div', { key: 'sub', className: 'mc-kicker' }, scenarioCurrentDailyProfit !== null ? `Aktueller Tagesgewinn ${fmtMoney(scenarioCurrentDailyProfit, reportCurrency)}` : 'Aktueller Tagesgewinn n/a'), + ]), + ]), + ] + ) : h('div', { key: 'scenario-empty', className: 'mc-empty' }, 'Waehle bei einem Angebot "Szenario", um die Auswirkung hier anzuzeigen.'), + ]), + panel('Ziele', 'Ziele koennen direkt oder aus einem Miner-Angebot heraus angelegt werden.', [ + h('div', { key: 'actions', className: 'mc-inline-row' }, [ + h('button', { + key: 'add-target', + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: () => setTargetModalOpen(true), + }, 'Ziel anlegen'), + ]), + h('div', { key: 'target-list', className: 'mc-table-shell' }, [ + h('table', { key: 'target-table', className: 'mc-table' }, [ + h('thead', { key: 'head' }, h('tr', null, ['Label', 'Betrag', 'Waehrung', 'Resttage', 'Ziel erreicht ca.', 'Sortierung', 'Aktiv', 'Aktion'].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'body' }, + currentTargets.length + ? currentTargets.map((target) => h('tr', { key: target.id || target.label }, [ + h('td', { key: 'label' }, target.label), + h('td', { key: 'amount' }, fmtNumber(target.effective_target_amount_fiat ?? target.target_amount_fiat, 2)), + h('td', { key: 'currency' }, [ + h('div', { key: 'currency-main' }, target.effective_currency || target.currency), + target.linked_offer_label ? h('div', { key: 'currency-offer', className: 'mc-kicker' }, `Angebot ${target.linked_offer_label}`) : null, + ]), + h('td', { key: 'days' }, target.remaining_days !== null && target.remaining_days !== undefined ? fmtNumber(target.remaining_days, 2) : 'n/a'), + h('td', { key: 'eta' }, target.target_eta_at ? fmtDateTime(target.target_eta_at) : 'n/a'), + h('td', { key: 'sort' }, String(target.sort_order ?? 0)), + h('td', { key: 'active' }, target.is_active ? 'ja' : 'nein'), + h('td', { key: 'action' }, + h('button', { + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => deleteTarget(target), + disabled: saving, + }, 'Loeschen') + ), + ])) + : [h('tr', { key: 'empty' }, h('td', { colSpan: 8 }, 'Noch keine Ziele hinterlegt.'))] + ), + ]), + ]), + ]), + costPlanModalOpen ? renderModal('Miner eintragen', [ + h('form', { key: 'form', className: 'mc-form', onSubmit: submitCostPlan }, [ + inputField('Label', 'text', costPlanForm.label, (value) => setCostPlanForm({ ...costPlanForm, label: value })), + inputField('Startdatum', 'datetime-local', costPlanForm.starts_at, (value) => setCostPlanForm({ ...costPlanForm, starts_at: value })), + inputField('Laufzeit in Monaten', 'number', String(costPlanForm.runtime_months), (value) => setCostPlanForm({ ...costPlanForm, runtime_months: Number(value) || 0 })), + inputField('Mining-Geschwindigkeit', 'number', costPlanForm.mining_speed_value, (value) => setCostPlanForm({ ...costPlanForm, mining_speed_value: value }), '0.0001'), + selectField('Mining-Einheit', costPlanForm.mining_speed_unit, speedUnits, (value) => setCostPlanForm({ ...costPlanForm, mining_speed_unit: value })), + inputField('Bonus-Geschwindigkeit', 'number', costPlanForm.bonus_speed_value, (value) => setCostPlanForm({ ...costPlanForm, bonus_speed_value: value }), '0.0001'), + selectField('Bonus-Einheit', costPlanForm.bonus_speed_unit, speedUnits, (value) => setCostPlanForm({ ...costPlanForm, bonus_speed_unit: value })), + inputField(`Basispreis in ${settingsForm.report_currency || 'EUR'}`, 'number', costPlanForm.base_price_amount, (value) => setCostPlanForm({ ...costPlanForm, base_price_amount: value }), '0.000001'), + selectField('Zahlungsart', costPlanForm.payment_type, [{ value: 'fiat', label: 'FIAT' }, { value: 'crypto', label: 'Krypto' }], (value) => setCostPlanForm({ ...costPlanForm, payment_type: value })), + textareaField('Notiz', costPlanForm.note, (value) => setCostPlanForm({ ...costPlanForm, note: value })), + h('label', { className: 'mc-checkbox' }, [ + h('input', { type: 'checkbox', checked: !!costPlanForm.auto_renew, onChange: (event) => setCostPlanForm({ ...costPlanForm, auto_renew: event.target.checked }) }), + 'Automatisch verlaengernd', + ]), + h('label', { className: 'mc-checkbox' }, [ + h('input', { type: 'checkbox', checked: !!costPlanForm.is_active, onChange: (event) => setCostPlanForm({ ...costPlanForm, is_active: event.target.checked }) }), + 'Aktiv', + ]), + h('div', { className: 'mc-inline-row' }, [ + h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setCostPlanModalOpen(false) }, 'Abbrechen'), + h('button', { type: 'submit', className: 'mc-button mc-button--secondary', disabled: saving }, saving ? 'Speichert …' : 'Miner speichern'), + ]), + ]), + ], () => setCostPlanModalOpen(false)) : null, + payoutModalOpen ? renderModal('Auszahlung erfassen', [ + h('form', { key: 'form', className: 'mc-form', onSubmit: submitPayout }, [ + inputField('Auszahlungszeitpunkt', 'datetime-local', payoutForm.payout_at, (value) => setPayoutForm({ ...payoutForm, payout_at: value })), + inputField('Coins', 'number', payoutForm.coins_amount, (value) => setPayoutForm({ ...payoutForm, coins_amount: value }), '0.000001'), + selectField('Waehrung', payoutForm.payout_currency, ['DOGE'].concat(selectableCurrencies.map((currency) => currency.code).filter((code) => code !== 'DOGE')), (value) => setPayoutForm({ ...payoutForm, payout_currency: value })), + textareaField('Notiz', payoutForm.note, (value) => setPayoutForm({ ...payoutForm, note: value })), + h('div', { className: 'mc-inline-row' }, [ + h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setPayoutModalOpen(false) }, 'Abbrechen'), + h('button', { type: 'submit', className: 'mc-button mc-button--secondary', disabled: saving }, saving ? 'Speichert …' : 'Auszahlung speichern'), + ]), + ]), + ], () => setPayoutModalOpen(false)) : null, + minerOfferModalOpen ? renderModal('Miner-Angebot anlegen', [ + h('form', { key: 'form', className: 'mc-form', onSubmit: submitMinerOffer }, [ + inputField('Label', 'text', minerOfferForm.label, (value) => setMinerOfferForm({ ...minerOfferForm, label: value })), + inputField('Laufzeit in Monaten', 'number', minerOfferForm.runtime_months, (value) => setMinerOfferForm({ ...minerOfferForm, runtime_months: value })), + inputField('Mining-Geschwindigkeit', 'number', minerOfferForm.mining_speed_value, (value) => setMinerOfferForm({ ...minerOfferForm, mining_speed_value: value }), '0.0001'), + selectField('Mining-Einheit', minerOfferForm.mining_speed_unit, speedUnits, (value) => setMinerOfferForm({ ...minerOfferForm, mining_speed_unit: value })), + inputField('Bonus-Geschwindigkeit', 'number', minerOfferForm.bonus_speed_value, (value) => setMinerOfferForm({ ...minerOfferForm, bonus_speed_value: value }), '0.0001'), + selectField('Bonus-Einheit', minerOfferForm.bonus_speed_unit, speedUnits, (value) => setMinerOfferForm({ ...minerOfferForm, bonus_speed_unit: value })), + inputField('Basispreis', 'number', minerOfferForm.base_price_amount, (value) => setMinerOfferForm({ ...minerOfferForm, base_price_amount: value }), '0.000001'), + selectField('Basiswährung', minerOfferForm.base_price_currency, selectableFiatCurrencies.map((currency) => currency.code), (value) => setMinerOfferForm({ ...minerOfferForm, base_price_currency: value })), + selectField('Zahlungsart', minerOfferForm.payment_type, [{ value: 'fiat', label: 'FIAT' }, { value: 'crypto', label: 'Krypto' }], (value) => setMinerOfferForm({ ...minerOfferForm, payment_type: value })), + h('label', { className: 'mc-checkbox' }, [ + h('input', { type: 'checkbox', checked: !!minerOfferForm.auto_renew, onChange: (event) => setMinerOfferForm({ ...minerOfferForm, auto_renew: event.target.checked }) }), + 'Automatische Verlängerung', + ]), + textareaField('Notiz', minerOfferForm.note, (value) => setMinerOfferForm({ ...minerOfferForm, note: value })), + h('label', { className: 'mc-checkbox' }, [ + h('input', { type: 'checkbox', checked: !!minerOfferForm.is_active, onChange: (event) => setMinerOfferForm({ ...minerOfferForm, is_active: event.target.checked }) }), + 'Als verfuegbar markieren', + ]), + h('div', { className: 'mc-inline-row' }, [ + h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setMinerOfferModalOpen(false) }, 'Abbrechen'), + h('button', { type: 'submit', className: 'mc-button mc-button--secondary', disabled: saving }, saving ? 'Speichert …' : 'Angebot speichern'), + ]), + ]), + ], () => setMinerOfferModalOpen(false)) : null, + purchaseMinerModalOpen ? renderModal('Neuen Miner mieten', [ + h('form', { key: 'form', className: 'mc-form', onSubmit: submitPurchaseMiner }, [ + selectField('Angebot', purchaseMinerForm.offer_id, [{ value: '', label: 'Bitte waehlen' }].concat(availableMinerOffers.map((offer) => ({ + value: String(offer.id), + label: `${offer.label} · ${fmtNumber(offer.effective_price_amount, 6)} ${offer.effective_price_currency}`, + }))), (value) => { + const offer = availableMinerOffers.find((item) => String(item.id) === String(value)); + setPurchaseMinerForm({ + offer_id: value, + purchased_at: purchaseMinerForm.purchased_at || new Date().toISOString().slice(0, 16), + total_cost_amount: offer && offer.effective_price_amount !== null && offer.effective_price_amount !== undefined ? String(offer.effective_price_amount) : '', + currency: offer?.effective_price_currency || offer?.base_price_currency || 'USD', + reference_price_amount: offer && offer.reference_price_amount !== null && offer.reference_price_amount !== undefined ? String(offer.reference_price_amount) : '', + reference_price_currency: offer?.reference_price_currency || '', + auto_renew: !!offer?.auto_renew, + note: purchaseMinerForm.note || '', + }); + }), + inputField('Mietdatum/-zeit', 'datetime-local', purchaseMinerForm.purchased_at, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, purchased_at: value })), + inputField('Exakter Mietpreis', 'number', purchaseMinerForm.total_cost_amount, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, total_cost_amount: value }), '0.000001'), + selectField('Mietwährung', purchaseMinerForm.currency, selectableCurrencies.map((currency) => currency.code), (value) => setPurchaseMinerForm({ ...purchaseMinerForm, currency: value })), + inputField('Referenzpreis', 'number', purchaseMinerForm.reference_price_amount, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, reference_price_amount: value }), '0.000001'), + selectField('Referenzwährung', purchaseMinerForm.reference_price_currency, [''].concat(selectableCurrencies.map((currency) => currency.code)), (value) => setPurchaseMinerForm({ ...purchaseMinerForm, reference_price_currency: value })), + h('label', { className: 'mc-checkbox' }, [ + h('input', { type: 'checkbox', checked: !!purchaseMinerForm.auto_renew, onChange: (event) => setPurchaseMinerForm({ ...purchaseMinerForm, auto_renew: event.target.checked }) }), + 'Automatische Verlängerung', + ]), + textareaField('Notiz', purchaseMinerForm.note, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, note: value })), + h('div', { className: 'mc-inline-row' }, [ + h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setPurchaseMinerModalOpen(false) }, 'Abbrechen'), + h('button', { type: 'submit', className: 'mc-button mc-button--secondary', disabled: saving }, saving ? 'Speichert …' : 'Miner mieten'), + ]), + ]), + ], () => setPurchaseMinerModalOpen(false)) : null, + targetModalOpen ? renderModal('Ziel anlegen', [ + h('form', { key: 'form', className: 'mc-form', onSubmit: submitTarget }, [ + inputField('Label', 'text', targetForm.label, (value) => setTargetForm({ ...targetForm, label: value })), + selectField('Angebots-Verknuepfung', targetForm.miner_offer_id || '', [{ value: '', label: 'Kein verknuepftes Angebot' }].concat(availableMinerOffers.map((offer) => ({ + value: String(offer.id), + label: `${offer.label} · ${fmtNumber(offer.base_price_amount ?? offer.effective_price_amount, 6)} ${offer.base_price_currency || offer.effective_price_currency}`, + }))), (value) => { + const offer = availableMinerOffers.find((item) => String(item.id) === String(value)); + setTargetForm({ + ...targetForm, + miner_offer_id: value, + label: targetForm.label || offer?.label || '', + target_amount_fiat: offer ? String(offer.base_price_amount ?? offer.effective_price_amount ?? '') : targetForm.target_amount_fiat, + currency: offer?.base_price_currency || offer?.effective_price_currency || targetForm.currency, + }); + }), + inputField('Betrag', 'number', targetForm.target_amount_fiat, (value) => setTargetForm({ ...targetForm, target_amount_fiat: value }), '0.01'), + selectField('Waehrung', targetForm.currency, selectableCurrencies.map((currency) => currency.code), (value) => setTargetForm({ ...targetForm, currency: value })), + inputField('Sortierung', 'number', String(targetForm.sort_order), (value) => setTargetForm({ ...targetForm, sort_order: Number(value) || 0 })), + h('label', { className: 'mc-checkbox' }, [ + h('input', { type: 'checkbox', checked: !!targetForm.is_active, onChange: (event) => setTargetForm({ ...targetForm, is_active: event.target.checked }) }), + 'Aktiv', + ]), + h('div', { className: 'mc-inline-row' }, [ + h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setTargetModalOpen(false) }, 'Abbrechen'), + h('button', { type: 'submit', className: 'mc-button mc-button--secondary', disabled: saving }, saving ? 'Speichert …' : 'Ziel speichern'), + ]), + ]), + ], () => setTargetModalOpen(false)) : null, + ]); + } + + return h('div', { className: 'mc-two-col' }, [ + h('div', { className: 'mc-stack' }, [ + panel('Initialisierung', 'Prueft den Tabellenstatus und kann das Mining-Checker Schema neu anlegen. Reset loescht bestehende miningcheck_ Tabellen inklusive Daten.', [ + h('div', { key: 'status', className: 'mc-form' }, [ + displayField('Status', schemaStatus.all_present ? 'Schema vollstaendig vorhanden' : 'Schema unvollstaendig'), + displayField('Vorhandene Tabellen', `${schemaStatus.present_count}/${schemaStatus.required_tables.length}`), + displayField('Fehlende Tabellen', schemaStatus.missing_tables.length ? schemaStatus.missing_tables.join(', ') : 'keine'), + displayField('Ausstehende Upgrades', schemaStatus.pending_upgrades.length ? schemaStatus.pending_upgrades.join(', ') : 'keine'), + ]), + h('form', { key: 'form', className: 'mc-form', onSubmit: initializeModule }, [ + h('label', { className: 'mc-checkbox' }, [ + h('input', { + key: 'drop-existing', + type: 'checkbox', + checked: !!initForm.drop_existing, + onChange: (event) => setInitForm({ drop_existing: event.target.checked }), + }), + 'Bestehende Mining-Checker Tabellen inkl. Daten loeschen und neu anlegen', + ]), + h('button', { + type: 'submit', + className: initForm.drop_existing ? 'mc-button mc-button--danger' : 'mc-button mc-button--primary', + disabled: saving, + }, saving ? 'Initialisiert …' : (initForm.drop_existing ? 'Reset + Schema neu anlegen' : 'Schema initialisieren')), + ]), + h('button', { + key: 'upgrade', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: upgradeDatabaseSchema, + disabled: saving, + }, saving ? 'Upgradet …' : 'DB auf neueste Version upgraden'), + ]), + panel('Datenbank-Test', 'Prueft, ob das Modul die Projekt-Datenbank erreichen und eine einfache Anfrage ausfuehren kann.', [ + dbCheck + ? h('div', { key: 'dbcheck-result', className: 'mc-form' }, [ + displayField('Status', dbCheck.ok ? 'Verbindung erfolgreich' : 'Verbindung fehlgeschlagen'), + displayField('Driver', dbCheck.driver || 'n/a'), + displayField('Datenbank', dbCheck.database || 'n/a'), + displayField('Tabellenpraefix', dbCheck.table_prefix || 'n/a'), + ]) + : h('div', { key: 'dbcheck-empty', className: 'mc-empty' }, 'Noch kein Verbindungstest ausgefuehrt.'), + h('button', { + key: 'dbcheck-button', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: testDatabaseConnection, + disabled: saving, + }, saving ? 'Prueft …' : 'DB-Verbindung testen'), + ]), + ]), + h('div', { className: 'mc-stack' }, [ + panel('Basis-Settings', 'Baseline bleibt als Referenzwert mit Datum und Uhrzeit bestehen.', h('form', { + className: 'mc-form', + onSubmit: submitSettings, + }, [ + inputField('Baseline Zeitpunkt', 'datetime-local', settingsForm.baseline_measured_at ? settingsForm.baseline_measured_at.replace(' ', 'T').slice(0, 16) : '', (value) => setSettingsForm({ ...settingsForm, baseline_measured_at: value })), + inputField('Baseline Coins', 'number', settingsForm.baseline_coins_total, (value) => setSettingsForm({ ...settingsForm, baseline_coins_total: value }), '0.000001'), + selectField('Standard-FIAT-Währung', settingsForm.report_currency || 'EUR', selectableFiatCurrencies.map((currency) => currency.code), (value) => setSettingsForm({ ...settingsForm, report_currency: value })), + selectField('Standard-Krypto-Währung', settingsForm.crypto_currency || 'DOGE', selectableCryptoCurrencies.map((currency) => currency.code), (value) => setSettingsForm({ ...settingsForm, crypto_currency: value })), + inputField('FX maximal in Stunden wiederverwenden', 'number', String(settingsForm.fx_max_age_hours || 3), (value) => setSettingsForm({ ...settingsForm, fx_max_age_hours: value }), '0.25'), + selectField('Modul-Layout', settingsForm.module_theme_mode || 'inherit', [ + { value: 'inherit', label: 'Wie Main-Site' }, + { value: 'custom', label: 'Custom' }, + ], (value) => setSettingsForm({ ...settingsForm, module_theme_mode: value })), + settingsForm.module_theme_mode === 'custom' + ? selectField('Custom-Farbschema', settingsForm.module_theme_accent || 'teal', [ + { value: 'teal', label: 'Mining Teal' }, + { value: 'logo', label: 'Logo Mix' }, + { value: 'pink', label: 'Pink' }, + { value: 'cyan', label: 'Cyan' }, + { value: 'orange', label: 'Orange' }, + { value: 'green', label: 'Gruen' }, + ], (value) => setSettingsForm({ ...settingsForm, module_theme_accent: value })) + : null, + h('button', { + type: 'submit', + className: 'mc-button mc-button--primary', + disabled: saving, + }, saving ? 'Speichert …' : 'Settings speichern'), + ])), + panel('Modulrechte', 'Steuert, wer den Mining-Checker auf der Startseite sieht und direkt aufrufen darf.', h('form', { + className: 'mc-form', + onSubmit: submitModuleAuth, + }, [ + h('label', { className: 'mc-checkbox' }, [ + h('input', { + key: 'required', + type: 'checkbox', + checked: !!moduleAuthForm.required, + onChange: (event) => setModuleAuthForm({ ...moduleAuthForm, required: event.target.checked }), + }), + 'Login fuer dieses Modul erforderlich', + ]), + inputField('Erlaubte Benutzer / Subs', 'text', moduleAuthForm.users, (value) => setModuleAuthForm({ ...moduleAuthForm, users: value })), + inputField('Erlaubte Gruppen', 'text', moduleAuthForm.groups, (value) => setModuleAuthForm({ ...moduleAuthForm, groups: value })), + h('div', { className: 'mc-text' }, + 'Mehrere Werte mit Komma trennen. Benutzerfeld akzeptiert Keycloak-Sub, Benutzername oder E-Mail. Leer bedeutet: jeder eingeloggte Benutzer darf das Modul nutzen.'), + h('button', { + type: 'submit', + className: 'mc-button mc-button--primary', + disabled: saving, + }, saving ? 'Speichert …' : 'Modulrechte speichern'), + ])), + ]), + ]); + } + + function panel(title, subtitle, content) { + return h('section', { className: 'mc-panel' }, [ + h(SectionTitle, { key: 'title', title, subtitle }), + h('div', { key: 'body', className: 'mc-panel-body' }, content), + ]); + } + + function fieldWrapper(label, child) { + return h('label', { className: 'mc-field' }, [ + h('span', { key: 'label', className: 'mc-field-label' }, label), + child, + ]); + } + + function inputField(label, type, value, onChange, step) { + return fieldWrapper(label, h('input', { + className: 'mc-input', + type, + step: step || undefined, + value: value, + onChange: (event) => onChange(event.target.value), + })); + } + + function selectField(label, value, options, onChange) { + return fieldWrapper(label, h('select', { + className: 'mc-select', + value, + onChange: (event) => onChange(event.target.value), + }, options.map((option) => { + const normalized = option && typeof option === 'object' + ? option + : { value: option, label: option || 'alle' }; + return h('option', { + key: normalized.value || 'empty', + value: normalized.value, + }, normalized.label || 'alle'); + }))); + } + + function textareaField(label, value, onChange) { + return fieldWrapper(label, h('textarea', { + className: 'mc-textarea', + value, + onChange: (event) => onChange(event.target.value), + })); + } + + function fileField(label, onChange) { + return fieldWrapper(label, h('input', { + className: 'mc-file', + type: 'file', + accept: 'image/png,image/jpeg,image/webp', + onChange: (event) => onChange(event.target.files && event.target.files[0] ? event.target.files[0] : null), + })); + } + + function displayField(label, value) { + return h('div', { className: 'mc-display-field' }, [ + h('div', { key: 'label', className: 'mc-field-label' }, label), + h('div', { key: 'value', className: 'mc-text' }, value || 'n/a'), + ]); + } + + function renderModal(title, content, onClose) { + return h('div', { + className: 'mc-modal-backdrop', + onClick: onClose, + }, [ + h('div', { + key: 'modal', + className: 'mc-modal', + onClick: (event) => event.stopPropagation(), + }, [ + h('div', { key: 'head', className: 'mc-section-head' }, [ + h('div', { key: 'title-wrap' }, [ + h('h3', { key: 'title' }, title), + ]), + h('button', { + key: 'close', + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: onClose, + }, 'Schliessen'), + ]), + h('div', { key: 'body', className: 'mc-panel-body' }, content), + ]), + ]); + } + + function formatSpeed(value, unit, label) { + if (value === null || value === undefined || value === '' || !unit) { + return ''; + } + + return `${label ? label + ' ' : ''}${fmtNumber(value, 4)} ${unit}`; + } + + function formatAdaptiveSpeed(valueMh) { + const numericValue = Number(valueMh); + if (!Number.isFinite(numericValue)) { + return 'n/a'; + } + + if (numericValue > 0 && numericValue < 1) { + return `${fmtNumber(numericValue * 1000, 2)} kH/s`; + } + + return `${fmtNumber(numericValue, 4)} MH/s`; + } + } + + 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 0000000..8cfed81 --- /dev/null +++ b/modules/mining-checker/bootstrap.php @@ -0,0 +1,16 @@ + '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 0000000..b04af69 --- /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/docs/README.md b/modules/mining-checker/docs/README.md new file mode 100644 index 0000000..5d11405 --- /dev/null +++ b/modules/mining-checker/docs/README.md @@ -0,0 +1,125 @@ +# 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` +- `POST /api/mining-checker/v1/projects/{projectKey}/fx-refresh` +- `POST /api/mining-checker/v1/projects/{projectKey}/currencies-refresh` +- `GET /api/mining-checker/v1/projects/{projectKey}/fx-history` + +## 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 + +Der Endpunkt `POST /api/mining-checker/v1/projects/{projectKey}/fx-refresh` holt aktuelle Fiat-Wechselkurse von `currencyapi.net` und speichert sie in `miningcheck_fx_rates`. + +Empfohlene Umgebungsvariablen: + +- `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=...` +- `MINING_CHECKER_FX_TIMEOUT=10` +- `MINING_CHECKER_FX_CACHE_TTL=21600` +- `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 `GET /api/v2/rates?base=...&output=json&key=...` abgefragt. Aus dem Response werden `base`, `rates` und `updated` uebernommen; `valid` muss `true` sein. Die API liefert mehr Waehrungen als benoetigt, der Mining-Checker filtert lokal auf die angeforderten Zielwaehrungen und speichert die Kurse danach normalisiert in `miningcheck_fx_fetches` und `miningcheck_fx_rates`. + +Pro Abruf entsteht genau ein Datensatz in `miningcheck_fx_fetches` mit Basiswaehrung, Provider und Stichtag. Alle Einzelkurse dieses Abrufs liegen darunter in `miningcheck_fx_rates` und teilen sich dieselbe `fetch_id`. Dadurch lassen sich Kurse innerhalb desselben Abrufs konsistent gegeneinander umrechnen. + +Wenn fuer eine benoetigte Umrechnung noch kein passender FIAT-Fetch gespeichert ist, faellt der Mining-Checker auf vorhandene Kurs-Snapshots aus den Mining-Messpunkten (`measurement_rates`) zurueck. + +Mit `POST /api/mining-checker/v1/projects/{projectKey}/currencies-refresh` kann die Waehrungstabelle einmalig oder bei Bedarf aus `GET /api/v2/currencies?output=json&key=...` synchronisiert werden. Dabei werden Code, Name, Symbol und Sortierung in `miningcheck_currencies` gespeichert. + +Die im Tab `Waehrungen` ausgewaehlten Favoriten werden in `miningcheck_settings.preferred_currencies` gespeichert. Dadurch ist die Auswahl geraeteuebergreifend verfuegbar. Fuer bestehende Installationen ist dafuer einmal ein Schema-Upgrade noetig. diff --git a/modules/mining-checker/module.json b/modules/mining-checker/module.json new file mode 100644 index 0000000..23792ad --- /dev/null +++ b/modules/mining-checker/module.json @@ -0,0 +1,10 @@ +{ + "name": "Mining-Checker", + "description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.", + "enabled_by_default": true, + "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 0000000..aa8d612 --- /dev/null +++ b/modules/mining-checker/pages/index.php @@ -0,0 +1,32 @@ +', '<\/script>', $moduleJs); +?> +
+
+
+ + + + diff --git a/modules/mining-checker/partials/.gitkeep b/modules/mining-checker/partials/.gitkeep new file mode 100644 index 0000000..e69de29 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 0000000..8df915e --- /dev/null +++ b/modules/mining-checker/sql/migrations/001_init.sql @@ -0,0 +1,123 @@ +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_currencies ( + code VARCHAR(10) NOT NULL PRIMARY KEY, + name VARCHAR(64) NOT NULL, + symbol VARCHAR(8) NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + is_crypto TINYINT(1) NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS miningcheck_currency_aliases ( + alias_code VARCHAR(10) NOT NULL PRIMARY KEY, + currency_code VARCHAR(10) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_currency_aliases_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code) ON DELETE CASCADE +); + +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 fk_mining_settings_daily_cost_currency_currency FOREIGN KEY (daily_cost_currency) REFERENCES miningcheck_currencies(code), + CONSTRAINT fk_mining_settings_report_currency_currency FOREIGN KEY (report_currency) REFERENCES miningcheck_currencies(code), + CONSTRAINT fk_mining_settings_crypto_currency_currency FOREIGN KEY (crypto_currency) REFERENCES miningcheck_currencies(code), + 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, + CONSTRAINT fk_mining_cost_plans_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code) +); + +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, + 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 fk_mining_measurements_price_currency_currency FOREIGN KEY (price_currency) REFERENCES miningcheck_currencies(code), + 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_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code), + 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) +); 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 0000000..f360132 --- /dev/null +++ b/modules/mining-checker/sql/migrations/002_seed_doge_main.sql @@ -0,0 +1,91 @@ +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_currencies (code, name, symbol, is_active, sort_order) +VALUES + ('EUR', 'Euro', 'EUR', 1, 10), + ('USD', 'US-Dollar', 'USD', 1, 20), + ('DOGE', 'Dogecoin', 'DOGE', 1, 100), + ('BTC', 'Bitcoin', 'BTC', 1, 110), + ('ETH', 'Ethereum', 'ETH', 1, 120), + ('LTC', 'Litecoin', 'LTC', 1, 130), + ('USDT', 'Tether', 'USDT', 1, 140), + ('USDC', 'USD Coin', 'USDC', 1, 150) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + symbol = VALUES(symbol), + is_active = VALUES(is_active), + sort_order = VALUES(sort_order); + +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 0000000..e31b622 --- /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 0000000..bc6e4be --- /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 0000000..1caf2fa --- /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 0000000..9bc3a1e --- /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 0000000..ddaf071 --- /dev/null +++ b/modules/mining-checker/sql/schema.mysql.sql @@ -0,0 +1,226 @@ +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_currencies ( + code VARCHAR(10) NOT NULL PRIMARY KEY, + name VARCHAR(64) NOT NULL, + symbol VARCHAR(8) NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + is_crypto TINYINT(1) NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS miningcheck_currency_aliases ( + alias_code VARCHAR(10) NOT NULL PRIMARY KEY, + currency_code VARCHAR(10) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_currency_aliases_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code) ON DELETE CASCADE +); + +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 fk_mining_settings_daily_cost_currency_currency FOREIGN KEY (daily_cost_currency) REFERENCES miningcheck_currencies(code), + CONSTRAINT fk_mining_settings_report_currency_currency FOREIGN KEY (report_currency) REFERENCES miningcheck_currencies(code), + CONSTRAINT fk_mining_settings_crypto_currency_currency FOREIGN KEY (crypto_currency) REFERENCES miningcheck_currencies(code), + 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, + CONSTRAINT fk_mining_cost_plans_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code) +); + +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, + 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 fk_mining_measurements_price_currency_currency FOREIGN KEY (price_currency) REFERENCES miningcheck_currencies(code), + 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_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 fk_mining_measurement_rates_base_currency_currency FOREIGN KEY (base_currency) REFERENCES miningcheck_currencies(code), + CONSTRAINT fk_mining_measurement_rates_quote_currency_currency FOREIGN KEY (quote_currency) REFERENCES miningcheck_currencies(code), + 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, + CONSTRAINT fk_mining_payouts_payout_currency_currency FOREIGN KEY (payout_currency) REFERENCES miningcheck_currencies(code), + KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_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_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code), + 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_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, + CONSTRAINT fk_mining_miner_offers_base_price_currency_currency FOREIGN KEY (base_price_currency) REFERENCES miningcheck_currencies(code) +); + +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, + CONSTRAINT fk_mining_purchased_miners_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code), + CONSTRAINT fk_mining_purchased_miners_reference_price_currency_currency FOREIGN KEY (reference_price_currency) REFERENCES miningcheck_currencies(code) +); + +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, + CONSTRAINT fk_mining_fx_fetches_base_currency_currency FOREIGN KEY (base_currency) REFERENCES miningcheck_currencies(code), + 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, + CONSTRAINT fk_mining_fx_rates_currency_code_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code) +); diff --git a/modules/mining-checker/sql/schema.pgsql.sql b/modules/mining-checker/sql/schema.pgsql.sql new file mode 100644 index 0000000..605922e --- /dev/null +++ b/modules/mining-checker/sql/schema.pgsql.sql @@ -0,0 +1,244 @@ +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_currencies ( + code VARCHAR(10) PRIMARY KEY, + name VARCHAR(64) NOT NULL, + symbol VARCHAR(8), + is_active BOOLEAN NOT NULL DEFAULT TRUE, + is_crypto BOOLEAN NOT NULL DEFAULT FALSE, + sort_order INTEGER NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS miningcheck_currency_aliases ( + alias_code VARCHAR(10) PRIMARY KEY, + currency_code VARCHAR(10) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_currency_aliases_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code) ON DELETE CASCADE +); + +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 fk_mining_settings_daily_cost_currency_currency FOREIGN KEY (daily_cost_currency) REFERENCES miningcheck_currencies(code), + CONSTRAINT fk_mining_settings_report_currency_currency FOREIGN KEY (report_currency) REFERENCES miningcheck_currencies(code), + CONSTRAINT fk_mining_settings_crypto_currency_currency FOREIGN KEY (crypto_currency) REFERENCES miningcheck_currencies(code), + 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, + CONSTRAINT fk_mining_cost_plans_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code) +); + +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, + price_per_coin NUMERIC(20,8), + price_currency VARCHAR(10), + 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 fk_mining_measurements_price_currency_currency FOREIGN KEY (price_currency) REFERENCES miningcheck_currencies(code), + 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 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 fk_mining_measurement_rates_base_currency_currency FOREIGN KEY (base_currency) REFERENCES miningcheck_currencies(code), + CONSTRAINT fk_mining_measurement_rates_quote_currency_currency FOREIGN KEY (quote_currency) REFERENCES miningcheck_currencies(code), + 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, + CONSTRAINT fk_mining_payouts_payout_currency_currency FOREIGN KEY (payout_currency) REFERENCES miningcheck_currencies(code) +); + +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_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_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code), + 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_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, + CONSTRAINT fk_mining_miner_offers_base_price_currency_currency FOREIGN KEY (base_price_currency) REFERENCES miningcheck_currencies(code) +); + +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, + CONSTRAINT fk_mining_purchased_miners_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code), + CONSTRAINT fk_mining_purchased_miners_reference_price_currency_currency FOREIGN KEY (reference_price_currency) REFERENCES miningcheck_currencies(code) +); + +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, + CONSTRAINT fk_mining_fx_fetches_base_currency_currency FOREIGN KEY (base_currency) REFERENCES miningcheck_currencies(code) +); + +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, + CONSTRAINT fk_mining_fx_rates_currency_code_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code) +); + +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 0000000..ddaf071 --- /dev/null +++ b/modules/mining-checker/sql/schema.sql @@ -0,0 +1,226 @@ +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_currencies ( + code VARCHAR(10) NOT NULL PRIMARY KEY, + name VARCHAR(64) NOT NULL, + symbol VARCHAR(8) NULL, + is_active TINYINT(1) NOT NULL DEFAULT 1, + is_crypto TINYINT(1) NOT NULL DEFAULT 0, + sort_order INT NOT NULL DEFAULT 0 +); + +CREATE TABLE IF NOT EXISTS miningcheck_currency_aliases ( + alias_code VARCHAR(10) NOT NULL PRIMARY KEY, + currency_code VARCHAR(10) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_currency_aliases_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code) ON DELETE CASCADE +); + +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 fk_mining_settings_daily_cost_currency_currency FOREIGN KEY (daily_cost_currency) REFERENCES miningcheck_currencies(code), + CONSTRAINT fk_mining_settings_report_currency_currency FOREIGN KEY (report_currency) REFERENCES miningcheck_currencies(code), + CONSTRAINT fk_mining_settings_crypto_currency_currency FOREIGN KEY (crypto_currency) REFERENCES miningcheck_currencies(code), + 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, + CONSTRAINT fk_mining_cost_plans_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code) +); + +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, + 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 fk_mining_measurements_price_currency_currency FOREIGN KEY (price_currency) REFERENCES miningcheck_currencies(code), + 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_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 fk_mining_measurement_rates_base_currency_currency FOREIGN KEY (base_currency) REFERENCES miningcheck_currencies(code), + CONSTRAINT fk_mining_measurement_rates_quote_currency_currency FOREIGN KEY (quote_currency) REFERENCES miningcheck_currencies(code), + 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, + CONSTRAINT fk_mining_payouts_payout_currency_currency FOREIGN KEY (payout_currency) REFERENCES miningcheck_currencies(code), + KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_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_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code), + 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_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, + CONSTRAINT fk_mining_miner_offers_base_price_currency_currency FOREIGN KEY (base_price_currency) REFERENCES miningcheck_currencies(code) +); + +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, + CONSTRAINT fk_mining_purchased_miners_currency_currency FOREIGN KEY (currency) REFERENCES miningcheck_currencies(code), + CONSTRAINT fk_mining_purchased_miners_reference_price_currency_currency FOREIGN KEY (reference_price_currency) REFERENCES miningcheck_currencies(code) +); + +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, + CONSTRAINT fk_mining_fx_fetches_base_currency_currency FOREIGN KEY (base_currency) REFERENCES miningcheck_currencies(code), + 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, + CONSTRAINT fk_mining_fx_rates_currency_code_currency FOREIGN KEY (currency_code) REFERENCES miningcheck_currencies(code) +); diff --git a/modules/mining-checker/sql/seed.sql b/modules/mining-checker/sql/seed.sql new file mode 100644 index 0000000..dc78cd8 --- /dev/null +++ b/modules/mining-checker/sql/seed.sql @@ -0,0 +1,91 @@ +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_currencies (code, name, symbol, is_active, sort_order) +VALUES + ('EUR', 'Euro', 'EUR', 1, 10), + ('USD', 'US-Dollar', 'USD', 1, 20), + ('DOGE', 'Dogecoin', 'DOGE', 1, 100), + ('BTC', 'Bitcoin', 'BTC', 1, 110), + ('ETH', 'Ethereum', 'ETH', 1, 120), + ('LTC', 'Litecoin', 'LTC', 1, 130), + ('USDT', 'Tether', 'USDT', 1, 140), + ('USDC', 'USD Coin', 'USDC', 1, 150) +ON DUPLICATE KEY UPDATE + name = VALUES(name), + symbol = VALUES(symbol), + is_active = VALUES(is_active), + sort_order = VALUES(sort_order); + +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 0000000..8b9bc4f --- /dev/null +++ b/modules/mining-checker/src/Api/Router.php @@ -0,0 +1,1747 @@ +moduleBasePath = rtrim($moduleBasePath, '/'); + $this->config = ModuleConfig::load($this->moduleBasePath); + $requestUri = (string) ($_SERVER['REQUEST_URI'] ?? ''); + $requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?: ''); + $debugConfig = $this->config->debug(); + $debugEnabled = filter_var( + $_GET['debug'] + ?? $_SERVER['HTTP_X_MINING_DEBUG'] + ?? $_COOKIE['mining_checker_debug'] + ?? ($debugConfig['enabled'] ?? false), + FILTER_VALIDATE_BOOL + ); + $latestDebugFilePath = rtrim($this->config->debugDir(), '/') . '/latest-server.json'; + $isLatestDebugRequest = str_ends_with($requestPath, '/api/mining-checker/v1/debug/latest') + || $requestPath === 'api/mining-checker/v1/debug/latest' + || $requestPath === '/api/mining-checker/v1/debug/latest'; + $debugFilePath = ($debugEnabled && !$isLatestDebugRequest) ? $latestDebugFilePath : null; + DebugState::setLatestFilePath($latestDebugFilePath); + $this->debug = new DebugTrace((bool) $debugEnabled, $debugFilePath); + $this->debug->add('router.init', [ + 'method' => $_SERVER['REQUEST_METHOD'] ?? 'GET', + 'uri' => $requestUri, + ]); + $this->ocr = new OcrService($this->config); + } + + public function handle(string $relativePath): never + { + try { + $method = strtoupper((string) ($_SERVER['REQUEST_METHOD'] ?? 'GET')); + $path = trim($relativePath, '/'); + + 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] ?? ''), '/'); + + 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 === 'upgrade' && $method === 'POST') { + $this->respond([ + 'data' => $this->schemaManager()->upgradeSchemaDirect(), + ], 201); + } + + if ($resource === 'rebuild-preserve-core' && $method === 'POST') { + $this->respond([ + 'data' => $this->rebuildPreservingCoreData($projectKey), + ], 201); + } + + if ($resource === 'connection-test' && $method === 'GET') { + $this->respond(['data' => $this->connectionStatus()]); + } + + if ($resource === 'fx-refresh' && $method === 'POST') { + $this->respond(['data' => $this->refreshFxRates(Http::input())], 201); + } + + if ($resource === 'fx-probe' && $method === 'POST') { + $this->respond(['data' => $this->probeFxRates(Http::input())], 200); + } + + if ($resource === 'currencies-refresh' && $method === 'POST') { + $this->respond(['data' => $this->refreshCurrencies()], 201); + } + + if ($resource === 'currencies-probe' && $method === 'POST') { + $this->respond(['data' => $this->probeCurrencies()], 200); + } + + if ($resource === 'fx-history' && $method === 'GET') { + $this->respond(['data' => $this->fxHistory()]); + } + + if ($resource === 'bootstrap' && $method === 'GET') { + $this->respond(['data' => $this->bootstrap($projectKey)]); + } + + if ($resource === 'measurements' && $method === 'GET') { + Http::json(['data' => $this->measurements($projectKey)]); + } + + if ($resource === 'measurements' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->createMeasurement($projectKey, Http::input())], 201); + } + + if ($resource === 'measurements-import' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->importMeasurements($projectKey, Http::input())], 201); + } + + if ($resource === 'ocr-preview' && $method === 'POST') { + if (!isset($_FILES['image'])) { + throw new ApiException('Feld image fehlt.', 422); + } + + $preview = $this->ocr->preview($_FILES['image'], array_merge($_POST, ['project_key' => $projectKey])); + Http::json(['data' => $preview], 201); + } + + if ($resource === 'settings' && $method === 'GET') { + Http::json(['data' => $this->settings($projectKey)]); + } + + if ($resource === 'settings' && $method === 'PUT') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->saveSettings($projectKey, Http::input())]); + } + + if ($resource === 'targets' && $method === 'GET') { + Http::json(['data' => $this->targets($projectKey)]); + } + + if ($resource === 'targets' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->saveTarget($projectKey, Http::input())], 201); + } + + if (preg_match('~^targets/(\d+)$~', $resource, $matches) && $method === 'PATCH') { + Http::json(['data' => $this->updateTarget($projectKey, (int) $matches[1], Http::input())]); + } + + if (preg_match('~^targets/(\d+)$~', $resource, $matches) && $method === 'DELETE') { + $this->deleteTarget($projectKey, (int) $matches[1]); + Http::json(['data' => ['deleted' => true]]); + } + + if ($resource === 'dashboards' && $method === 'GET') { + Http::json(['data' => $this->dashboards($projectKey)]); + } + + if ($resource === 'dashboards' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->saveDashboard($projectKey, Http::input())], 201); + } + + if ($resource === 'dashboard-data' && $method === 'GET') { + Http::json(['data' => $this->dashboardData($projectKey, $_GET)]); + } + + if ($resource === 'seed-import' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->seedImporter()->import($projectKey)], 201); + } + + if ($resource === 'cost-plans' && $method === 'GET') { + Http::json(['data' => $this->costPlans($projectKey)]); + } + + if ($resource === 'cost-plans' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->saveCostPlan($projectKey, Http::input())], 201); + } + + if ($resource === 'payouts' && $method === 'GET') { + Http::json(['data' => $this->payouts($projectKey)]); + } + + if ($resource === 'payouts' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->savePayout($projectKey, Http::input())], 201); + } + + if ($resource === 'miner-offers' && $method === 'GET') { + Http::json(['data' => $this->minerOffers($projectKey)]); + } + + if ($resource === 'miner-offers' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->saveMinerOffer($projectKey, Http::input())], 201); + } + + if ($resource === 'purchased-miners' && $method === 'GET') { + Http::json(['data' => $this->purchasedMiners($projectKey)]); + } + + if (preg_match('~^purchased-miners/(\d+)$~', $resource, $matches) && $method === 'PATCH') { + Http::json(['data' => $this->updatePurchasedMiner($projectKey, (int) $matches[1], Http::input())]); + } + + if (preg_match('~^miner-offers/(\d+)/purchase$~', $resource, $matches) && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->purchaseMiner($projectKey, (int) $matches[1], Http::input())], 201); + } + + if ($resource === 'currencies' && $method === 'GET') { + Http::json(['data' => $this->currencies()]); + } + + if ($resource === 'currency-aliases' && $method === 'GET') { + Http::json(['data' => $this->currencyAliases()]); + } + + if ($resource === 'currency-aliases' && $method === 'POST') { + Http::json(['data' => $this->saveCurrencyAlias(Http::input())], 201); + } + + throw new ApiException('Ressource nicht gefunden.', 404, ['resource' => $resource, 'method' => $method]); + } catch (ApiException $exception) { + Http::json([ + 'error' => $exception->getMessage(), + 'context' => $exception->context(), + ], $exception->statusCode()); + } catch (\Throwable $exception) { + Http::json([ + 'error' => 'Unerwarteter Mining-Checker Fehler.', + 'context' => ['message' => $exception->getMessage()], + ], 500); + } + } + + private function bootstrap(string $projectKey): array + { + $settings = $this->settings($projectKey); + $measurements = $this->measurements($projectKey); + $targets = $this->targets($projectKey); + $dashboards = $this->dashboards($projectKey); + + return [ + 'project' => $this->repository()->getProject($projectKey), + 'settings' => $settings, + 'measurements' => $measurements, + 'targets' => $targets, + 'dashboards' => $dashboards, + 'summary' => $this->analytics()->buildSummary($measurements, $settings, $targets), + ]; + } + + 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 + { + $requiredTables = [ + $this->config->tablePrefix() . 'projects', + $this->config->tablePrefix() . 'currencies', + $this->config->tablePrefix() . 'settings', + $this->config->tablePrefix() . 'cost_plans', + $this->config->tablePrefix() . 'measurements', + $this->config->tablePrefix() . 'fx_fetches', + $this->config->tablePrefix() . 'measurement_rates', + $this->config->tablePrefix() . 'payouts', + $this->config->tablePrefix() . 'targets', + $this->config->tablePrefix() . 'dashboard_definitions', + $this->config->tablePrefix() . 'miner_offers', + $this->config->tablePrefix() . 'purchased_miners', + ]; + + $presentTables = $this->fetchTablesByPrefix($this->config->tablePrefix()); + $presentRequired = array_values(array_intersect($requiredTables, $presentTables)); + $missingTables = array_values(array_diff($requiredTables, $presentRequired)); + $pendingUpgrades = []; + + if (in_array($this->config->tablePrefix() . 'cost_plans', $presentRequired, true)) { + $columns = $this->fetchColumns($this->config->tablePrefix() . 'cost_plans'); + foreach (['mining_speed_value', 'mining_speed_unit', 'bonus_speed_value', 'bonus_speed_unit'] as $column) { + if (!in_array($column, $columns, true)) { + $pendingUpgrades[] = 'cost_plan_speed_columns'; + break; + } + } + } + + if ( + !in_array($this->config->tablePrefix() . 'fx_fetches', $presentTables, true) || + !in_array($this->config->tablePrefix() . 'fx_rates', $presentTables, true) + ) { + $pendingUpgrades[] = 'fx_rates_table'; + } + + return [ + 'required_tables' => $requiredTables, + 'present_tables' => $presentRequired, + 'missing_tables' => $missingTables, + 'present_count' => count($presentRequired), + 'missing_count' => count($missingTables), + 'pending_upgrades' => array_values(array_unique($pendingUpgrades)), + 'pending_upgrade_count' => count(array_unique($pendingUpgrades)), + 'all_present' => $missingTables === [], + ]; + } + + private function rebuildPreservingCoreData(string $projectKey): array + { + $backup = [ + 'project' => $this->repository()->getProject($projectKey), + 'settings' => $this->safeRead(fn () => $this->repository()->getSettings($projectKey)), + 'currencies' => $this->safeRead(fn () => $this->repository()->listCurrencies(), []), + 'cost_plans' => $this->safeRead(fn () => $this->repository()->listCostPlans($projectKey), []), + 'measurements' => $this->safeRead(fn () => $this->repository()->listMeasurements($projectKey, 5000), []), + 'payouts' => $this->safeRead(fn () => $this->repository()->listPayouts($projectKey), []), + 'targets' => $this->safeRead(fn () => $this->repository()->listTargets($projectKey), []), + 'dashboards' => $this->safeRead(fn () => $this->repository()->listDashboards($projectKey), []), + 'miner_offers' => $this->safeRead(fn () => $this->repository()->listMinerOffers($projectKey), []), + 'purchased_miners' => $this->safeRead(fn () => $this->repository()->listPurchasedMiners($projectKey), []), + ]; + + $result = $this->schemaManager()->rebuildSchemaDirect(); + + $this->pdo = null; + $this->repository = null; + $this->schemaManager = null; + $this->fx = null; + $this->analytics = null; + $this->seedImporter = null; + + $projectName = is_array($backup['project']) ? ($backup['project']['project_name'] ?? null) : null; + $this->repository()->ensureProject($projectKey, is_string($projectName) ? $projectName : null); + + foreach ($backup['currencies'] as $currency) { + $this->repository()->saveCurrency([ + 'code' => $currency['code'], + 'name' => $currency['name'], + 'symbol' => $currency['symbol'] ?? null, + 'is_active' => !empty($currency['is_active']) ? 1 : 0, + 'sort_order' => (int) ($currency['sort_order'] ?? 0), + ]); + } + + if (is_array($backup['settings'])) { + $this->repository()->saveSettings($projectKey, [ + 'baseline_measured_at' => $backup['settings']['baseline_measured_at'], + 'baseline_coins_total' => $backup['settings']['baseline_coins_total'], + 'daily_cost_amount' => $backup['settings']['daily_cost_amount'], + 'daily_cost_currency' => $backup['settings']['daily_cost_currency'], + 'report_currency' => $backup['settings']['report_currency'] ?? 'EUR', + 'crypto_currency' => $backup['settings']['crypto_currency'] ?? 'DOGE', + 'display_timezone' => $backup['settings']['display_timezone'] ?? 'Europe/Berlin', + 'fx_max_age_hours' => $backup['settings']['fx_max_age_hours'] ?? 3, + '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, + ]); + } + + 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)) { + $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['targets'] as $target) { + $this->repository()->saveTarget($projectKey, [ + 'label' => $target['label'], + 'target_amount_fiat' => $target['target_amount_fiat'], + 'currency' => $target['currency'], + 'miner_offer_id' => $target['miner_offer_id'] ?? 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['miner_offers'] as $offer) { + $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, + ]); + } + + foreach ($backup['purchased_miners'] as $miner) { + $this->repository()->restorePurchasedMiner($projectKey, [ + 'miner_offer_id' => 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, + ]); + } + + return array_merge($result, [ + 'restored' => [ + 'measurements' => count($backup['measurements']), + 'purchased_miners' => count($backup['purchased_miners']), + 'cost_plans' => count($backup['cost_plans']), + 'payouts' => count($backup['payouts']), + 'targets' => count($backup['targets']), + 'dashboards' => count($backup['dashboards']), + 'miner_offers' => count($backup['miner_offers']), + ], + ]); + } + + private function safeRead(callable $callback, mixed $fallback = null): mixed + { + try { + return $callback(); + } catch (\Throwable) { + return $fallback; + } + } + + private function fetchTablesByPrefix(string $prefix): array + { + $pdo = $this->pdo(); + $driver = (string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + + 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 . '%']); + return array_map('strval', $statement->fetchAll(PDO::FETCH_COLUMN) ?: []); + } + + private function fetchColumns(string $tableName): array + { + $pdo = $this->pdo(); + $driver = (string) $pdo->getAttribute(PDO::ATTR_DRIVER_NAME); + + if ($driver === 'pgsql') { + $sql = 'SELECT column_name FROM information_schema.columns WHERE table_schema = current_schema() AND table_name = :table_name ORDER BY ordinal_position'; + } else { + $sql = 'SELECT column_name FROM information_schema.columns WHERE table_schema = DATABASE() AND table_name = :table_name ORDER BY ordinal_position'; + } + + $statement = $pdo->prepare($sql); + $statement->execute(['table_name' => $tableName]); + return array_map('strval', $statement->fetchAll(PDO::FETCH_COLUMN) ?: []); + } + + private function refreshFxRates(array $input): array + { + $base = strtoupper(trim((string) ($input['base'] ?? 'EUR'))); + $this->debug->add('fx.refresh.start', [ + 'base' => $base, + ]); + + try { + $result = $this->fx()->refreshLatestRates(null, $base); + $this->debug->add('fx.refresh.end', [ + 'base' => $base, + 'fetch_id' => $result['fetch']['id'] ?? null, + 'rate_count' => is_array($result['rates'] ?? null) ? count($result['rates']) : null, + ]); + return $result; + } catch (\Throwable $exception) { + $this->debug->add('fx.refresh.error', [ + 'base' => $base, + 'message' => $exception->getMessage(), + ]); + throw $exception; + } + } + + private function probeFxRates(array $input): array + { + $base = strtoupper(trim((string) ($input['base'] ?? 'EUR'))); + return $this->fx()->probeLatestRates($base); + } + + private function refreshCurrencies(): array + { + return $this->fx()->refreshCurrencyCatalog(); + } + + private function probeCurrencies(): array + { + return $this->fx()->probeCurrencyCatalog(); + } + + private function fxHistory(): array + { + return $this->repository()->listFxRates(30); + } + + private function settings(string $projectKey): array + { + $settings = $this->repository()->getSettings($projectKey); + $base = is_array($settings) ? $settings : [ + 'project_key' => $projectKey, + 'baseline_measured_at' => null, + 'baseline_coins_total' => null, + 'daily_cost_amount' => null, + 'daily_cost_currency' => 'EUR', + 'report_currency' => 'EUR', + 'crypto_currency' => 'DOGE', + 'display_timezone' => 'Europe/Berlin', + 'fx_max_age_hours' => 3, + 'module_theme_mode' => 'inherit', + 'module_theme_accent' => 'teal', + 'preferred_currencies' => ['DOGE', 'USD', 'EUR'], + ]; + if (!$this->isValidTimezone((string) ($base['display_timezone'] ?? ''))) { + $base['display_timezone'] = 'Europe/Berlin'; + } + if (!is_numeric($base['fx_max_age_hours'] ?? null) || (float) $base['fx_max_age_hours'] <= 0) { + $base['fx_max_age_hours'] = 3; + } + if (!in_array((string) ($base['module_theme_mode'] ?? ''), ['inherit', 'custom'], true)) { + $base['module_theme_mode'] = 'inherit'; + } + if (!in_array((string) ($base['module_theme_accent'] ?? ''), ['teal', 'logo', 'pink', 'cyan', 'orange', 'green'], true)) { + $base['module_theme_accent'] = 'teal'; + } + + $base['cost_plans'] = $this->costPlans($projectKey); + $base['currencies'] = $this->currencies(); + $base['payouts'] = $this->payouts($projectKey); + $base['miner_offers'] = $this->minerOffers($projectKey); + $base['purchased_miners'] = $this->purchasedMiners($projectKey); + $base['measurement_rates'] = $this->measurementRates($projectKey); + return $base; + } + + private function saveSettings(string $projectKey, array $input): array + { + $existingSettings = $this->repository()->getSettings($projectKey) ?? []; + $displayTimezone = $this->requiredTimezone( + $input['display_timezone'] ?? ($existingSettings['display_timezone'] ?? 'Europe/Berlin'), + 'display_timezone' + ); + $settings = [ + 'baseline_measured_at' => $this->requiredDateTime($input['baseline_measured_at'] ?? null, 'baseline_measured_at', $displayTimezone), + 'baseline_coins_total' => $this->requiredDecimal($input['baseline_coins_total'] ?? null, 'baseline_coins_total'), + 'daily_cost_amount' => $this->requiredDecimal($input['daily_cost_amount'] ?? null, 'daily_cost_amount'), + 'daily_cost_currency' => $this->requiredCurrency($input['daily_cost_currency'] ?? null, 'daily_cost_currency'), + 'report_currency' => $this->requiredCurrency($input['report_currency'] ?? 'EUR', 'report_currency'), + 'crypto_currency' => $this->requiredCurrency($input['crypto_currency'] ?? 'DOGE', 'crypto_currency'), + 'display_timezone' => $displayTimezone, + 'fx_max_age_hours' => $this->requiredPositiveDecimal($input['fx_max_age_hours'] ?? 3, 'fx_max_age_hours'), + 'module_theme_mode' => $this->requiredEnum($input['module_theme_mode'] ?? 'inherit', 'module_theme_mode', ['inherit', 'custom']), + 'module_theme_accent' => $this->requiredEnum($input['module_theme_accent'] ?? 'teal', 'module_theme_accent', ['teal', 'logo', 'pink', 'cyan', 'orange', 'green']), + 'preferred_currencies' => $this->optionalCurrencyList($input['preferred_currencies'] ?? []), + ]; + + $this->assertCurrencyType($settings['report_currency'], false, 'report_currency'); + $this->assertCurrencyType($settings['crypto_currency'], true, 'crypto_currency'); + + $this->repository()->saveSettings($projectKey, $settings); + return $this->settings($projectKey); + } + + private function measurements(string $projectKey): array + { + $settings = $this->settings($projectKey); + $rows = $this->repository()->listMeasurements($projectKey, 500); + return $this->analytics()->enrichMeasurements($rows, $settings); + } + + private function createMeasurement(string $projectKey, array $input): array + { + $projectTimezone = $this->projectTimezone($projectKey); + $source = $this->enumValue($input['source'] ?? 'manual', ['manual', 'image_ocr', 'seed_import'], 'source'); + $payload = [ + 'measured_at' => $source === 'seed_import' + ? $this->requiredDateTime($input['measured_at'] ?? null, 'measured_at', $projectTimezone) + : $this->currentTimestamp(), + 'coins_total' => $this->requiredDecimal($input['coins_total'] ?? null, 'coins_total'), + 'price_per_coin' => $this->optionalDecimal($input['price_per_coin'] ?? null), + 'price_currency' => $this->optionalCurrency($input['price_currency'] ?? null), + 'note' => $this->optionalString($input['note'] ?? null, 2000), + 'source' => $source, + 'image_path' => $this->optionalString($input['image_path'] ?? null, 255), + 'ocr_raw_text' => $this->optionalString($input['ocr_raw_text'] ?? null, 65535), + 'ocr_confidence' => $this->optionalDecimal($input['ocr_confidence'] ?? null), + 'ocr_flags' => $this->optionalArray($input['ocr_flags'] ?? null), + ]; + + 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); + $created = $this->repository()->createMeasurement($projectKey, $payload); + $this->captureMeasurementRates($projectKey, $created); + $measurements = $this->measurements($projectKey); + return $measurements[array_key_last($measurements)]; + } + + private function importMeasurements(string $projectKey, array $input): array + { + $rawText = trim((string) ($input['rows_text'] ?? '')); + if ($rawText === '') { + throw new ApiException('rows_text ist erforderlich.', 422); + } + + $defaultCurrency = $this->optionalCurrency($input['default_currency'] ?? null); + $defaultSource = $this->enumValue($input['source'] ?? 'manual', ['manual', 'seed_import'], 'source'); + $lines = preg_split('/\R/', $rawText) ?: []; + + $imported = 0; + $duplicates = 0; + $errors = []; + + foreach ($lines as $index => $line) { + $lineNumber = $index + 1; + $trimmed = trim($line); + if ($trimmed === '' || str_starts_with($trimmed, '#') || str_starts_with($trimmed, '//')) { + continue; + } + + try { + $payload = $this->parseImportLine($trimmed, $defaultCurrency, $defaultSource, $this->projectTimezone($projectKey)); + $this->syncCurrencyCatalogForMeasurement($payload); + $result = $this->repository()->createMeasurementIfNotExists($projectKey, $payload); + if ($result === null) { + $duplicates++; + } else { + $this->captureMeasurementRates($projectKey, $result); + $imported++; + } + } catch (\Throwable $exception) { + $errors[] = [ + 'line' => $lineNumber, + 'input' => $trimmed, + 'message' => $exception instanceof ApiException ? $exception->getMessage() : 'Importzeile konnte nicht verarbeitet werden.', + ]; + } + } + + return [ + 'imported' => $imported, + 'duplicates_ignored' => $duplicates, + 'errors' => $errors, + 'error_count' => count($errors), + 'accepted_format' => 'DD.MM.YYYY HH:MM | coins_total | price_per_coin | currency | note', + ]; + } + + private function syncCurrencyCatalogForMeasurement(array $payload): void + { + $priceCurrency = strtoupper(trim((string) ($payload['price_currency'] ?? ''))); + if ($priceCurrency === '') { + return; + } + + $knownCodes = array_map( + static fn (array $currency): string => strtoupper((string) ($currency['code'] ?? '')), + $this->repository()->listCurrencies() + ); + + if (in_array($priceCurrency, $knownCodes, true)) { + return; + } + + $this->repository()->ensureCurrencyCode($priceCurrency, $priceCurrency); + + try { + $this->fx()->refreshCurrencyCatalog(); + } catch (\Throwable) { + // Measurement save must not fail because the external currency sync is unavailable. + } + } + + private function parseImportLine(string $line, ?string $defaultCurrency, string $defaultSource, string $projectTimezone): array + { + $parts = array_map('trim', explode('|', $line)); + if (count($parts) < 2) { + throw new ApiException('Zu wenige Felder. Erwartet: Datum/Zeit | Coins | Kurs | Waehrung | Notiz', 422); + } + + $measuredAt = $this->parseImportDateTime($parts[0] ?? '', $projectTimezone); + $coinsTotal = $this->requiredDecimal($parts[1] ?? null, 'coins_total'); + $pricePerCoin = $this->optionalDecimal($parts[2] ?? null); + $priceCurrency = $this->optionalCurrency($parts[3] ?? null) ?? $defaultCurrency; + $note = $this->optionalString($parts[4] ?? null, 2000); + + if (($pricePerCoin === null) xor ($priceCurrency === null)) { + throw new ApiException('Kurs und Waehrung muessen gemeinsam gesetzt werden oder beide leer bleiben.', 422); + } + + return [ + 'measured_at' => $measuredAt, + 'coins_total' => $coinsTotal, + 'price_per_coin' => $pricePerCoin, + 'price_currency' => $priceCurrency, + 'note' => $note, + 'source' => $defaultSource, + 'image_path' => null, + 'ocr_raw_text' => null, + 'ocr_confidence' => null, + 'ocr_flags' => ['paste_import'], + ]; + } + + private function parseImportDateTime(string $value, string $projectTimezone): string + { + $normalized = trim($value); + if ($normalized === '') { + throw new ApiException('Datum/Zeit fehlt.', 422); + } + + $patterns = [ + 'd.m.Y H:i:s', + 'd.m.Y H:i', + 'd.m.y H:i:s', + 'd.m.y H:i', + 'Y-m-d H:i:s', + 'Y-m-d H:i', + ]; + + $timezone = new \DateTimeZone($projectTimezone); + foreach ($patterns as $pattern) { + $date = \DateTimeImmutable::createFromFormat($pattern, $normalized, $timezone); + if ($date instanceof \DateTimeImmutable) { + return $date->setTimezone($this->utcTimezone())->format('Y-m-d H:i:s'); + } + } + + try { + $date = new \DateTimeImmutable($normalized, $timezone); + } catch (\Throwable) { + throw new ApiException('Datum/Zeit konnte nicht gelesen werden.', 422); + } + + return $date->setTimezone($this->utcTimezone())->format('Y-m-d H:i:s'); + } + + private function targets(string $projectKey): array + { + return $this->repository()->listTargets($projectKey); + } + + private function costPlans(string $projectKey): array + { + return $this->repository()->listCostPlans($projectKey); + } + + private function payouts(string $projectKey): array + { + return $this->repository()->listPayouts($projectKey); + } + + private function measurementRates(string $projectKey): array + { + return $this->repository()->listMeasurementRates($projectKey); + } + + private function currencies(): array + { + return $this->repository()->listCurrencies(); + } + + private function currencyAliases(): array + { + return $this->repository()->listCurrencyAliases(); + } + + private function saveCurrencyAlias(array $input): array + { + $aliasCode = strtoupper(trim((string) ($input['alias_code'] ?? ''))); + $currencyCode = $this->requiredCurrency($input['currency_code'] ?? null, 'currency_code'); + + if (!preg_match('/^[A-Z0-9]{3,10}$/', $aliasCode)) { + throw new ApiException('Feld alias_code muss ein gueltiger Waehrungscode sein.', 422); + } + + return $this->repository()->saveCurrencyAlias($aliasCode, $currencyCode); + } + + private function minerOffers(string $projectKey): array + { + return $this->repository()->listMinerOffers($projectKey); + } + + private function purchasedMiners(string $projectKey): array + { + return $this->repository()->listPurchasedMiners($projectKey); + } + + private function saveTarget(string $projectKey, array $input): array + { + $payload = [ + 'label' => $this->requiredString($input['label'] ?? null, 'label', 120), + 'target_amount_fiat' => $this->requiredDecimal($input['target_amount_fiat'] ?? null, 'target_amount_fiat'), + 'currency' => $this->requiredCurrency($input['currency'] ?? null, 'currency'), + 'miner_offer_id' => $this->optionalPositiveInt($input['miner_offer_id'] ?? null), + 'is_active' => !empty($input['is_active']) ? 1 : 0, + 'sort_order' => isset($input['sort_order']) ? (int) $input['sort_order'] : 0, + ]; + + $this->assertTargetOfferExists($projectKey, $payload['miner_offer_id']); + + return $this->repository()->saveTarget($projectKey, $payload); + } + + private function updateTarget(string $projectKey, int $targetId, array $input): array + { + $payload = [ + 'label' => $this->requiredString($input['label'] ?? null, 'label', 120), + 'target_amount_fiat' => $this->requiredDecimal($input['target_amount_fiat'] ?? null, 'target_amount_fiat'), + 'currency' => $this->requiredCurrency($input['currency'] ?? null, 'currency'), + 'miner_offer_id' => $this->optionalPositiveInt($input['miner_offer_id'] ?? null), + 'is_active' => !empty($input['is_active']) ? 1 : 0, + 'sort_order' => isset($input['sort_order']) ? (int) $input['sort_order'] : 0, + ]; + + $this->assertTargetOfferExists($projectKey, $payload['miner_offer_id']); + + return $this->repository()->updateTarget($projectKey, $targetId, $payload); + } + + private function deleteTarget(string $projectKey, int $targetId): void + { + $this->repository()->deleteTarget($projectKey, $targetId); + } + + private function assertTargetOfferExists(string $projectKey, ?int $offerId): void + { + if ($offerId === null) { + return; + } + + if ($this->repository()->getMinerOffer($projectKey, $offerId) === null) { + throw new ApiException( + 'Verknuepftes Miner-Angebot wurde nicht gefunden.', + 422, + ['field' => 'miner_offer_id', 'miner_offer_id' => $offerId] + ); + } + } + + private function dashboards(string $projectKey): array + { + $dashboards = $this->repository()->listDashboards($projectKey); + foreach ($dashboards as &$dashboard) { + if (is_array($dashboard['filters_json'] ?? null)) { + $dashboard['filters'] = $dashboard['filters_json']; + } elseif (is_string($dashboard['filters_json'] ?? null) && $dashboard['filters_json'] !== '') { + $decoded = json_decode($dashboard['filters_json'], true); + $dashboard['filters'] = is_array($decoded) ? $decoded : []; + } else { + $dashboard['filters'] = []; + } + } + + return $dashboards; + } + + private function saveCostPlan(string $projectKey, array $input): array + { + $projectTimezone = $this->projectTimezone($projectKey); + $payload = [ + 'label' => $this->requiredString($input['label'] ?? null, 'label', 120), + 'purchased_at' => $this->requiredDateTime($input['starts_at'] ?? null, 'starts_at', $projectTimezone), + 'runtime_months' => $this->requiredPositiveInt($input['runtime_months'] ?? null, 'runtime_months'), + 'mining_speed_value' => $this->optionalDecimal($input['mining_speed_value'] ?? null), + 'mining_speed_unit' => $this->optionalSpeedUnit($input['mining_speed_unit'] ?? null), + 'bonus_speed_value' => $this->optionalDecimal($input['bonus_speed_value'] ?? null), + 'bonus_speed_unit' => $this->optionalSpeedUnit($input['bonus_speed_unit'] ?? null), + 'auto_renew' => !empty($input['auto_renew']) ? 1 : 0, + 'base_price_amount' => $this->requiredDecimal($input['base_price_amount'] ?? ($input['total_cost_amount'] ?? null), 'base_price_amount'), + 'payment_type' => $this->enumValue($input['payment_type'] ?? 'fiat', ['fiat', 'crypto'], 'payment_type'), + 'note' => $this->optionalString($input['note'] ?? null, 1000), + 'is_active' => !empty($input['is_active']) ? 1 : 0, + ]; + + if (($payload['mining_speed_value'] === null) xor ($payload['mining_speed_unit'] === null)) { + throw new ApiException('Mining-Geschwindigkeit und Einheit muessen gemeinsam gesetzt oder beide leer sein.', 422); + } + + if (($payload['bonus_speed_value'] === null) xor ($payload['bonus_speed_unit'] === null)) { + throw new ApiException('Bonus-Geschwindigkeit und Einheit muessen gemeinsam gesetzt oder beide leer sein.', 422); + } + + $settings = $this->settings($projectKey); + $fiatCurrency = $this->requiredCurrency($settings['report_currency'] ?? 'EUR', 'report_currency'); + $cryptoCurrency = $this->requiredCurrency($settings['crypto_currency'] ?? 'DOGE', 'crypto_currency'); + $payload['currency'] = $payload['payment_type'] === 'crypto' ? $cryptoCurrency : $fiatCurrency; + $payload['total_cost_amount'] = $payload['base_price_amount']; + $payload['reference_price_amount'] = $payload['base_price_amount']; + $payload['reference_price_currency'] = $fiatCurrency; + $payload['usd_reference_amount'] = $fiatCurrency === 'USD' ? $payload['base_price_amount'] : null; + + if ($payload['payment_type'] === 'crypto') { + $converted = $this->fx()->convert((float) $payload['base_price_amount'], $fiatCurrency, $cryptoCurrency); + if ($converted === null) { + throw new ApiException( + 'Basispreis konnte nicht in die eingestellte Krypto-Waehrung umgerechnet werden.', + 422, + ['base_currency' => $fiatCurrency, 'crypto_currency' => $cryptoCurrency] + ); + } + $payload['total_cost_amount'] = $converted; + } + + return $this->repository()->restorePurchasedMiner($projectKey, [ + 'miner_offer_id' => null, + 'purchased_at' => $payload['purchased_at'], + 'label' => $payload['label'], + 'runtime_months' => $payload['runtime_months'], + 'mining_speed_value' => $payload['mining_speed_value'], + 'mining_speed_unit' => $payload['mining_speed_unit'], + 'bonus_speed_value' => $payload['bonus_speed_value'], + 'bonus_speed_unit' => $payload['bonus_speed_unit'], + 'total_cost_amount' => $payload['total_cost_amount'], + 'currency' => $payload['currency'], + 'usd_reference_amount' => $payload['usd_reference_amount'], + 'reference_price_amount' => $payload['reference_price_amount'], + 'reference_price_currency' => $payload['reference_price_currency'], + 'auto_renew' => $payload['auto_renew'], + 'note' => $payload['note'], + 'is_active' => $payload['is_active'], + ]); + } + + private function savePayout(string $projectKey, array $input): array + { + $payload = [ + 'payout_at' => $this->requiredDateTime($input['payout_at'] ?? null, 'payout_at', $this->projectTimezone($projectKey)), + 'coins_amount' => $this->requiredDecimal($input['coins_amount'] ?? null, 'coins_amount'), + 'payout_currency' => $this->requiredCurrency($input['payout_currency'] ?? 'DOGE', 'payout_currency'), + 'note' => $this->optionalString($input['note'] ?? null, 1000), + ]; + + return $this->repository()->savePayout($projectKey, $payload); + } + + private function saveMinerOffer(string $projectKey, array $input): array + { + $payload = [ + 'label' => $this->requiredString($input['label'] ?? null, 'label', 120), + 'runtime_months' => $this->optionalPositiveInt($input['runtime_months'] ?? null), + 'mining_speed_value' => $this->optionalDecimal($input['mining_speed_value'] ?? null), + 'mining_speed_unit' => $this->optionalSpeedUnit($input['mining_speed_unit'] ?? null), + 'bonus_speed_value' => $this->optionalDecimal($input['bonus_speed_value'] ?? null), + 'bonus_speed_unit' => $this->optionalSpeedUnit($input['bonus_speed_unit'] ?? null), + 'base_price_amount' => $this->requiredDecimal($input['base_price_amount'] ?? ($input['reference_price_amount'] ?? $input['price_amount'] ?? null), 'base_price_amount'), + 'base_price_currency' => $this->requiredCurrency($input['base_price_currency'] ?? ($input['reference_price_currency'] ?? $input['price_currency'] ?? null), 'base_price_currency'), + 'payment_type' => $this->enumValue($input['payment_type'] ?? 'fiat', ['fiat', 'crypto'], 'payment_type'), + 'auto_renew' => !empty($input['auto_renew']) ? 1 : 0, + 'note' => $this->optionalString($input['note'] ?? null, 1000), + 'is_active' => !empty($input['is_active']) ? 1 : 0, + ]; + + $this->assertCurrencyType($payload['base_price_currency'], false, 'base_price_currency'); + + return $this->repository()->saveMinerOffer($projectKey, $payload); + } + + private function purchaseMiner(string $projectKey, int $offerId, array $input): array + { + $offer = $this->repository()->getMinerOffer($projectKey, $offerId); + if (!is_array($offer)) { + throw new ApiException('Miner-Angebot nicht gefunden.', 404); + } + + $purchaseCurrency = $this->optionalCurrency($input['currency'] ?? null) ?? (string) ($offer['effective_price_currency'] ?? $offer['price_currency'] ?? $offer['base_price_currency'] ?? ''); + $purchaseCost = $this->optionalDecimal($input['total_cost_amount'] ?? null); + if ($purchaseCost === null) { + $purchaseCost = $this->resolveOfferPurchaseCost(array_merge($offer, [ + 'price_currency' => $purchaseCurrency !== '' ? $purchaseCurrency : ($offer['price_currency'] ?? ''), + ])); + } + $referencePriceAmount = $this->optionalDecimal($input['reference_price_amount'] ?? ($offer['reference_price_amount'] ?? $offer['usd_reference_amount'] ?? null)); + $referencePriceCurrency = $this->optionalCurrency($input['reference_price_currency'] ?? ($offer['reference_price_currency'] ?? (is_numeric($offer['usd_reference_amount'] ?? null) ? 'USD' : null))); + $purchasedAt = array_key_exists('purchased_at', $input) + ? $this->requiredDateTime($input['purchased_at'], 'purchased_at', $this->projectTimezone($projectKey)) + : $this->currentTimestamp(); + + return $this->repository()->purchaseMiner($projectKey, $offerId, [ + 'purchased_at' => $purchasedAt, + 'label' => $offer['label'], + 'runtime_months' => $offer['runtime_months'] ?? null, + 'mining_speed_value' => $offer['mining_speed_value'] ?? null, + 'mining_speed_unit' => $offer['mining_speed_unit'] ?? null, + 'bonus_speed_value' => $offer['bonus_speed_value'] ?? null, + 'bonus_speed_unit' => $offer['bonus_speed_unit'] ?? null, + 'total_cost_amount' => $purchaseCost, + 'currency' => $purchaseCurrency !== '' ? $purchaseCurrency : $offer['price_currency'], + 'usd_reference_amount' => $offer['usd_reference_amount'] ?? null, + 'reference_price_amount' => $referencePriceAmount, + 'reference_price_currency' => $referencePriceCurrency, + 'auto_renew' => array_key_exists('auto_renew', $input) ? (!empty($input['auto_renew']) ? 1 : 0) : (!empty($offer['auto_renew']) ? 1 : 0), + 'note' => $this->optionalString($input['note'] ?? ($offer['note'] ?? null), 1000), + 'is_active' => 1, + ]); + } + + private function updatePurchasedMiner(string $projectKey, int $minerId, array $input): array + { + $miner = $this->repository()->getPurchasedMiner($projectKey, $minerId); + if (!is_array($miner)) { + throw new ApiException('Gemieteter Miner nicht gefunden.', 404); + } + + if (!array_key_exists('auto_renew', $input)) { + throw new ApiException('Es kann aktuell nur auto_renew geaendert werden.', 422, ['field' => 'auto_renew']); + } + + $offerId = is_numeric($miner['miner_offer_id'] ?? null) ? (int) $miner['miner_offer_id'] : null; + if ($offerId === null) { + throw new ApiException('Dieser Miner kann nicht ueber ein Angebot aktualisiert werden.', 422); + } + + $offer = $this->repository()->getMinerOffer($projectKey, $offerId); + if (!is_array($offer) || empty($offer['auto_renew'])) { + throw new ApiException('Dieser Miner unterstuetzt keine automatische Verlaengerung.', 422); + } + + return $this->repository()->updatePurchasedMinerAutoRenew( + $projectKey, + $minerId, + !empty($input['auto_renew']) + ); + } + + private function saveDashboard(string $projectKey, array $input): array + { + $payload = [ + 'name' => $this->requiredString($input['name'] ?? null, 'name', 160), + 'chart_type' => $this->enumValue($input['chart_type'] ?? null, ['line', 'bar', 'area', 'table'], 'chart_type'), + 'x_field' => $this->requiredString($input['x_field'] ?? null, 'x_field', 64), + 'y_field' => $this->requiredString($input['y_field'] ?? null, 'y_field', 64), + 'aggregation' => $this->enumValue($input['aggregation'] ?? 'none', ['none', 'sum', 'avg', 'min', 'max', 'count', 'latest'], 'aggregation'), + 'filters' => $this->optionalArray($input['filters'] ?? []) ?? [], + 'is_active' => !empty($input['is_active']) ? 1 : 0, + ]; + + return $this->repository()->saveDashboard($projectKey, $payload); + } + + private function dashboardData(string $projectKey, array $query): array + { + $measurements = $this->measurements($projectKey); + return $this->analytics()->dashboardData( + $measurements, + (string) ($query['x_field'] ?? 'measured_at'), + (string) ($query['y_field'] ?? 'coins_total'), + (string) ($query['aggregation'] ?? 'none'), + [ + 'source' => $query['source'] ?? null, + 'currency' => $query['currency'] ?? null, + 'date_from' => $query['date_from'] ?? null, + 'date_to' => $query['date_to'] ?? null, + ] + ); + } + + private function requiredString(mixed $value, string $field, int $maxLength): string + { + $normalized = trim((string) $value); + if ($normalized === '') { + throw new ApiException("Feld {$field} ist erforderlich.", 422); + } + if (mb_strlen($normalized) > $maxLength) { + throw new ApiException("Feld {$field} ist zu lang.", 422); + } + return $normalized; + } + + private function optionalString(mixed $value, int $maxLength): ?string + { + $normalized = trim((string) $value); + if ($normalized === '') { + return null; + } + if (mb_strlen($normalized) > $maxLength) { + throw new ApiException('Textwert ist zu lang.', 422); + } + return $normalized; + } + + private function requiredDecimal(mixed $value, string $field): float + { + if ($value === null || $value === '') { + throw new ApiException("Feld {$field} ist erforderlich.", 422); + } + if (!is_numeric((string) $value)) { + throw new ApiException("Feld {$field} muss numerisch sein.", 422); + } + return (float) $value; + } + + private function optionalDecimal(mixed $value): ?float + { + if ($value === null || $value === '') { + return null; + } + if (!is_numeric((string) $value)) { + throw new ApiException('Dezimalwert ist ungueltig.', 422); + } + return (float) $value; + } + + private function requiredEnum(mixed $value, string $field, array $allowed): string + { + $normalized = strtolower(trim((string) $value)); + if (!in_array($normalized, $allowed, true)) { + throw new ApiException("Feld {$field} ist ungueltig.", 422, [ + 'field' => $field, + 'allowed' => $allowed, + ]); + } + return $normalized; + } + + private function requiredCurrency(mixed $value, string $field): string + { + $currency = strtoupper(trim((string) $value)); + if (!preg_match('/^[A-Z0-9]{3,10}$/', $currency)) { + throw new ApiException("Feld {$field} muss ein gueltiger Waehrungscode sein.", 422); + } + + $resolved = $this->repository()->resolveCurrencyCode($currency); + if ($resolved !== null && !empty($resolved['code'])) { + return (string) $resolved['code']; + } + + throw new ApiException( + "Feld {$field} verweist auf keinen vorhandenen Waehrungsrecord.", + 422, + [ + 'field' => $field, + 'missing_currency' => $currency, + 'hint' => 'Lege zuerst die Waehrung an oder hinterlege einen Alias auf einen bestehenden Waehrungsrecord.', + 'available_currencies' => array_slice(array_map( + static fn (array $item): string => (string) ($item['code'] ?? ''), + $this->repository()->listCurrencies() + ), 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 + { + $resolved = $this->repository()->resolveCurrencyCode($code); + $currency = is_array($resolved) ? ($resolved['currency'] ?? null) : null; + $isCrypto = !empty($currency['is_crypto']); + if ($isCrypto !== $expectedCrypto) { + throw new ApiException( + $expectedCrypto + ? "Feld {$field} muss auf eine Krypto-Waehrung zeigen." + : "Feld {$field} muss auf eine FIAT-Waehrung zeigen.", + 422, + ['field' => $field, 'currency' => $code, 'expected_crypto' => $expectedCrypto] + ); + } + } + + private function optionalSpeedUnit(mixed $value): ?string + { + if ($value === null || $value === '') { + return null; + } + + $unit = trim((string) $value); + if (!in_array($unit, ['kH/s', 'MH/s'], true)) { + throw new ApiException('Geschwindigkeitseinheit ist ungueltig.', 422, ['allowed' => ['kH/s', 'MH/s']]); + } + + return $unit; + } + + private function requiredPositiveInt(mixed $value, string $field): int + { + if ($value === null || $value === '' || !is_numeric((string) $value)) { + throw new ApiException("Feld {$field} muss numerisch sein.", 422); + } + + $intValue = (int) $value; + if ($intValue <= 0) { + throw new ApiException("Feld {$field} muss groesser als 0 sein.", 422); + } + + return $intValue; + } + + private function requiredPositiveDecimal(mixed $value, string $field): float + { + if ($value === null || $value === '' || !is_numeric((string) $value)) { + throw new ApiException("Feld {$field} muss numerisch sein.", 422); + } + + $floatValue = (float) $value; + if ($floatValue <= 0) { + throw new ApiException("Feld {$field} muss groesser als 0 sein.", 422); + } + + return $floatValue; + } + + private function optionalPositiveInt(mixed $value): ?int + { + if ($value === null || $value === '') { + return null; + } + + return $this->requiredPositiveInt($value, 'value'); + } + + private function requiredDateTime(mixed $value, string $field, ?string $timezone = null): string + { + $normalized = trim((string) $value); + if ($normalized === '') { + throw new ApiException("Feld {$field} ist erforderlich.", 422); + } + + $sourceTimezone = new \DateTimeZone($timezone ?: 'UTC'); + $formats = [ + 'Y-m-d H:i:s', + 'Y-m-d H:i', + 'Y-m-d\TH:i:s', + 'Y-m-d\TH:i', + ]; + + $date = null; + foreach ($formats as $format) { + $parsed = \DateTimeImmutable::createFromFormat($format, $normalized, $sourceTimezone); + if ($parsed instanceof \DateTimeImmutable) { + $date = $parsed; + break; + } + } + + if (!$date instanceof \DateTimeImmutable) { + try { + $date = new \DateTimeImmutable($normalized, $sourceTimezone); + } catch (\Throwable) { + $date = null; + } + } + + if (!$date instanceof \DateTimeImmutable) { + throw new ApiException("Feld {$field} muss ein gueltiges Datum sein.", 422); + } + + return $date->setTimezone($this->utcTimezone())->format('Y-m-d H:i:s'); + } + + private function currentTimestamp(): string + { + return (new \DateTimeImmutable('now', $this->utcTimezone()))->format('Y-m-d H:i:s'); + } + + private function requiredTimezone(mixed $value, string $field): string + { + $timezone = trim((string) $value); + if (!$this->isValidTimezone($timezone)) { + throw new ApiException("Feld {$field} enthaelt keine gueltige Zeitzone.", 422); + } + + return $timezone; + } + + private function isValidTimezone(string $timezone): bool + { + return $timezone !== '' && in_array($timezone, \DateTimeZone::listIdentifiers(), true); + } + + private function projectTimezone(string $projectKey): string + { + $settings = $this->repository()->getSettings($projectKey); + $timezone = is_array($settings) ? (string) ($settings['display_timezone'] ?? '') : ''; + return $this->isValidTimezone($timezone) ? $timezone : 'Europe/Berlin'; + } + + private function utcTimezone(): \DateTimeZone + { + static $timezone = null; + if (!$timezone instanceof \DateTimeZone) { + $timezone = new \DateTimeZone('UTC'); + } + + return $timezone; + } + + private function enumValue(mixed $value, array $allowed, string $field): string + { + $normalized = trim((string) $value); + if (!in_array($normalized, $allowed, true)) { + throw new ApiException("Feld {$field} enthaelt einen ungueltigen Wert.", 422, ['allowed' => $allowed]); + } + return $normalized; + } + + private function optionalArray(mixed $value): ?array + { + if ($value === null || $value === '') { + return null; + } + + if (is_array($value)) { + return $value; + } + + if (is_string($value)) { + $decoded = json_decode($value, true); + if (is_array($decoded)) { + return $decoded; + } + } + + throw new ApiException('Array-Wert ist ungueltig.', 422); + } + + private function optionalCurrencyList(mixed $value): array + { + if ($value === null || $value === '') { + return []; + } + + if (!is_array($value)) { + throw new ApiException('preferred_currencies muss ein Array sein.', 422); + } + + $result = []; + foreach ($value as $item) { + $currency = $this->requiredCurrency($item, 'preferred_currencies'); + if (!in_array($currency, $result, true)) { + $result[] = $currency; + } + } + + return $result; + } + + private function captureMeasurementRates(string $projectKey, array $measurement): void + { + $measurementId = (int) ($measurement['id'] ?? 0); + $price = is_numeric($measurement['price_per_coin'] ?? null) ? (float) $measurement['price_per_coin'] : null; + $priceCurrency = strtoupper(trim((string) ($measurement['price_currency'] ?? ''))); + if ($measurementId <= 0) { + return; + } + + $this->ensureFreshFxForMeasurement($projectKey, $priceCurrency !== '' ? $priceCurrency : 'USD'); + + $rates = []; + if ($price !== null && $price > 0 && $priceCurrency !== '') { + $rates[] = ['base_currency' => 'DOGE', 'quote_currency' => $priceCurrency, 'rate' => $price, 'provider' => 'measurement']; + $rates[] = ['base_currency' => $priceCurrency, 'quote_currency' => 'DOGE', 'rate' => 1 / $price, 'provider' => 'measurement']; + + foreach (['USD', 'EUR'] as $fiatCurrency) { + if ($fiatCurrency === $priceCurrency) { + continue; + } + + $convertedPrice = $this->priceForCurrency($price, $priceCurrency, $fiatCurrency); + if ($convertedPrice === null || $convertedPrice <= 0) { + continue; + } + + $rates[] = ['base_currency' => 'DOGE', 'quote_currency' => $fiatCurrency, 'rate' => $convertedPrice, 'provider' => 'derived']; + $rates[] = ['base_currency' => $fiatCurrency, 'quote_currency' => 'DOGE', 'rate' => 1 / $convertedPrice, 'provider' => 'derived']; + } + } else { + foreach (['USD', 'EUR'] as $fiatCurrency) { + $fxPrice = $this->fx()->rate('DOGE', $fiatCurrency); + if ($fxPrice === null || $fxPrice <= 0) { + continue; + } + + $rates[] = ['base_currency' => 'DOGE', 'quote_currency' => $fiatCurrency, 'rate' => $fxPrice, 'provider' => 'fx']; + $rates[] = ['base_currency' => $fiatCurrency, 'quote_currency' => 'DOGE', 'rate' => 1 / $fxPrice, 'provider' => 'fx']; + } + } + + $eurUsd = $this->fx()->rate('EUR', 'USD'); + if ($eurUsd !== null) { + $rates[] = ['base_currency' => 'EUR', 'quote_currency' => 'USD', 'rate' => $eurUsd, 'provider' => 'fx']; + $rates[] = ['base_currency' => 'USD', 'quote_currency' => 'EUR', 'rate' => 1 / $eurUsd, 'provider' => 'fx']; + } + + if ($rates !== []) { + $this->repository()->replaceMeasurementRates($measurementId, $projectKey, $rates); + } + } + + private function priceForCurrency(float $price, string $fromCurrency, string $toCurrency): ?float + { + $from = strtoupper(trim($fromCurrency)); + $to = strtoupper(trim($toCurrency)); + if ($from === $to) { + return $price; + } + + $converted = $this->fx()->convert($price, $from, $to); + return is_numeric($converted) ? (float) $converted : null; + } + + private function ensureFreshFxForMeasurement(string $projectKey, string $priceCurrency): void + { + $normalizedCurrency = strtoupper(trim($priceCurrency)); + if ($normalizedCurrency === '') { + return; + } + + $settings = $this->settings($projectKey); + $maxAgeHours = is_numeric($settings['fx_max_age_hours'] ?? null) ? (float) $settings['fx_max_age_hours'] : 3.0; + $this->fx()->ensureFreshLatestRates($maxAgeHours, 'USD'); + } + + private function resolveOfferPurchaseCost(array $offer): float + { + $purchaseCurrency = (string) ($offer['price_currency'] ?? ''); + $baseAmount = is_numeric($offer['base_price_amount'] ?? null) + ? (float) $offer['base_price_amount'] + : (is_numeric($offer['reference_price_amount'] ?? null) + ? (float) $offer['reference_price_amount'] + : (is_numeric($offer['usd_reference_amount'] ?? null) ? (float) $offer['usd_reference_amount'] : null)); + $baseCurrency = (string) ($offer['base_price_currency'] ?? $offer['reference_price_currency'] ?? (is_numeric($offer['usd_reference_amount'] ?? null) ? 'USD' : '')); + + if ($purchaseCurrency !== '' && $baseAmount !== null && $baseAmount > 0 && $baseCurrency !== '') { + $converted = $this->fx()->convert($baseAmount, $baseCurrency, $purchaseCurrency); + if (is_numeric($converted) && (float) $converted > 0) { + return (float) $converted; + } + } + + return (float) ($baseAmount ?? $offer['price_amount'] ?? 0); + } + + private function pdo(): PDO + { + if ($this->pdo === null) { + $this->debug->add('db.connect.start'); + $this->pdo = ConnectionFactory::make($this->config); + $this->debug->add('db.connect.end', [ + 'driver' => (string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME), + ]); + } + + return $this->pdo; + } + + private function repository(): MiningRepository + { + if ($this->repository === null) { + $this->repository = new MiningRepository($this->pdo(), $this->config->tablePrefix(), $this->debug, $this->ownerSub()); + } + + return $this->repository; + } + + private function ownerSub(): string + { + $user = app()->auth()->user(); + $sub = is_array($user) ? trim((string) ($user['sub'] ?? '')) : ''; + if ($sub !== '') { + return $sub; + } + + if (app()->auth()->isEnabled()) { + throw new ApiException('Keycloak-Sub fehlt. Bitte erneut anmelden.', 401); + } + + return 'local'; + } + + private function schemaManager(): SchemaManager + { + if ($this->schemaManager === null) { + $this->schemaManager = new SchemaManager($this->pdo(), $this->config->tablePrefix(), $this->moduleBasePath); + } + + return $this->schemaManager; + } + + private function fx(): FxService + { + if ($this->fx === null) { + $fxConfig = $this->config->fx(); + $this->fx = new FxService( + $this->repository(), + (string) ($fxConfig['url'] ?? 'https://currencyapi.net'), + (string) ($fxConfig['currencies_url'] ?? 'https://currencyapi.net'), + (int) ($fxConfig['timeout'] ?? 10), + (int) ($fxConfig['cache_ttl'] ?? 21600), + (bool) ($fxConfig['auto_fetch_on_miss'] ?? false), + (string) ($fxConfig['provider'] ?? 'currencyapi'), + (string) ($fxConfig['api_key'] ?? ''), + $this->debug + ); + } + + return $this->fx; + } + + private function respond(array $payload, int $statusCode = 200): never + { + $trace = $this->debug->export(); + if ($trace !== []) { + $payload['debug'] = $trace; + } + + Http::json($payload, $statusCode); + } + + private function 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 0000000..4ce1bf6 --- /dev/null +++ b/modules/mining-checker/src/Domain/AnalyticsService.php @@ -0,0 +1,937 @@ +fx = $fx; + } + + public function enrichMeasurements(array $measurements, array $settings): array + { + $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'] : []; + $measurementRates = is_array($settings['measurement_rates'] ?? null) ? $settings['measurement_rates'] : []; + + $baselineTs = $this->utcTimestamp($baselineAt); + $previous = null; + $previousIntervalRate = null; + $result = []; + $payoutIndex = 0; + $cumulativePayouts = 0.0; + $latestPriceByCurrency = []; + + foreach ($measurements as $row) { + $measuredTs = $this->utcTimestamp((string) $row['measured_at']); + while (isset($payouts[$payoutIndex])) { + $payoutTs = $this->utcTimestamp((string) ($payouts[$payoutIndex]['payout_at'] ?? '')); + if ($payoutTs <= 0 || $payoutTs > $measuredTs) { + break; + } + + $cumulativePayouts += (float) ($payouts[$payoutIndex]['coins_amount'] ?? 0); + $payoutIndex++; + } + + $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; + + $intervalHours = null; + $intervalGrowth = null; + $perHourInterval = null; + $perDayInterval = null; + + if (is_array($previous)) { + $intervalHours = max(0.0, ($measuredTs - ($this->utcTimestamp((string) $previous['measured_at']) ?: $measuredTs)) / 3600); + $intervalGrowth = $effectiveCoinsTotal - (float) ($previous['coins_total_effective'] ?? $previous['coins_total']); + $perHourInterval = $intervalHours > 0 ? $intervalGrowth / $intervalHours : null; + $perDayInterval = $perHourInterval !== null ? $perHourInterval * 24 : null; + } + + $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($measurementRates, (int) ($row['id'] ?? 0)); + 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, 'DOGE', $fallbackCurrency); + 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 = $this->effectiveDailyCost($costPlans, $measuredTs, $priceCurrency); + $currentValue = $price !== null ? $visibleCoinsTotal * $price : null; + $currentValueEffective = $price !== null ? $effectiveCoinsTotal * $price : null; + $theoreticalDailyRevenue = ($price !== null && $perDayInterval !== null) ? $perDayInterval * $price : null; + $theoreticalDailyProfit = ( + $theoreticalDailyRevenue !== null && + $effectiveDailyCost !== null + ) ? $theoreticalDailyRevenue - $effectiveDailyCost : null; + $breakEvenPricePerCoin = ( + $effectiveDailyCost !== null && + $perDayInterval !== null && + $perDayInterval > 0 + ) ? $effectiveDailyCost / $perDayInterval : null; + $profitMarginPercent = ( + $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]; + } + + $result[] = array_merge($row, [ + 'coins_total_visible' => $this->roundOrNull($visibleCoinsTotal, 6), + 'coins_total_effective' => $this->roundOrNull($effectiveCoinsTotal, 6), + '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), + '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), + 'trend_label' => $trendLabel, + 'effective_price_per_coin' => $this->roundOrNull($price, 8), + 'effective_price_currency' => $priceCurrency, + 'price_is_fallback' => $rawPrice === null && $price !== null, + '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 : [], + ]); + + if ($perHourInterval !== null) { + $previousIntervalRate = $perHourInterval; + } + $previous = $row; + } + + return $result; + } + + public function buildSummary(array $measurements, array $settings, array $targets): array + { + if ($measurements === []) { + return [ + 'latest_measurement' => null, + 'baseline' => $settings, + 'targets' => [], + 'payouts' => [], + 'miner_offers' => [], + ]; + } + + $latest = $measurements[array_key_last($measurements)]; + $latestPriceByCurrency = []; + foreach ($measurements as $measurement) { + if ($measurement['price_per_coin'] !== null && $measurement['price_currency'] !== null) { + $latestPriceByCurrency[(string) $measurement['price_currency']] = (float) $measurement['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 ($minerOffers 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['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); + $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'] ?? ''); + $investedCapital = $latestCurrency !== '' + ? $this->totalInvestmentBasis( + is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], + $purchasedMiners, + $this->utcTimestamp((string) ($latest['measured_at'] ?? '')), + $latestCurrency + ) + : null; + $currentDailyRevenue = is_numeric($latest['theoretical_daily_revenue'] ?? null) ? (float) $latest['theoretical_daily_revenue'] : null; + $breakEvenRemainingAmount = $investedCapital; + $breakEvenDaysOverall = ( + $investedCapital !== null && + $currentDailyRevenue !== null && + $currentDailyRevenue > 0 + ) ? ($investedCapital / $currentDailyRevenue) : null; + $latestSummary = array_merge($latest, [ + 'invested_capital' => $this->roundOrNull($investedCapital, 8), + 'break_even_remaining_amount' => $this->roundOrNull($breakEvenRemainingAmount, 8), + 'break_even_days_overall' => $this->roundOrNull($breakEvenDaysOverall, 4), + ]); + $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), + ]); + $offerSummary = array_map( + fn (array $offer): array => $this->enrichOfferScenario( + $offer, + $latestSummary, + $currentHashrateMh, + is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [], + $purchasedMiners + ), + $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), + ], + '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): ?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 + ); + if ($convertedDailyCost === null) { + continue; + } + + $matched = true; + $dailyTotal += $convertedDailyCost; + } + + return $matched ? $dailyTotal : null; + } + + private function convertLatestPrice(array $latestPriceByCurrency, string $targetCurrency): ?float + { + foreach ($latestPriceByCurrency as $sourceCurrency => $price) { + $converted = $this->convertAmount((float) $price, (string) $sourceCurrency, $targetCurrency); + 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 $measurementRates, int $measurementId): array + { + if ($measurementId <= 0 || $measurementRates === []) { + return []; + } + + $prices = []; + foreach ($measurementRates as $row) { + if ((int) ($row['measurement_id'] ?? 0) !== $measurementId) { + continue; + } + + $baseCurrency = strtoupper(trim((string) ($row['base_currency'] ?? ''))); + $quoteCurrency = strtoupper(trim((string) ($row['target_currency'] ?? $row['quote_currency'] ?? ''))); + $rate = is_numeric($row['rate'] ?? null) ? (float) $row['rate'] : null; + + if ($baseCurrency !== 'DOGE' || $quoteCurrency === '' || $rate === null || $rate <= 0) { + continue; + } + + $prices[$quoteCurrency] = $rate; + } + + return $prices; + } + + private function convertAmount(?float $amount, ?string $fromCurrency, ?string $toCurrency): ?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; + } + + if ($this->fx === null) { + return null; + } + + return $this->fx->convert($amount, $from, $to); + } + + 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 totalPurchasedMinerCost(array $purchasedMiners, string $targetCurrency, ?int $measurementTs = 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 (!$this->entryIsCovered($miner, $measurementTs > 0 ? $measurementTs : null, 'purchased_at')) { + 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); + if ($converted === null) { + continue; + } + + $matched = true; + $total += $converted; + } + + return $matched ? $total : null; + } + + private function totalInvestmentBasis(array $costPlans, array $purchasedMiners, int $measurementTs, string $targetCurrency): ?float + { + $target = strtoupper(trim($targetCurrency)); + if ($target === '') { + return null; + } + + $total = 0.0; + $matched = false; + + $purchasedTotal = $this->totalPurchasedMinerCost($purchasedMiners, $target, $measurementTs); + if ($purchasedTotal !== null) { + $matched = true; + $total += $purchasedTotal; + } + + 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 > 0 && $measurementTs < $startTs)) { + continue; + } + + $runtimeDays = $runtimeMonths * 30.4375; + $endTs = (int) round($startTs + ($runtimeDays * 86400)); + $isCovered = !empty($plan['auto_renew']) || $measurementTs <= 0 || $measurementTs <= $endTs; + if (!$isCovered) { + 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); + 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 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); + if ($convertedReference !== null && $convertedReference > 0) { + $effectivePriceAmount = $convertedReference; + } + } + $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)) : 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) : 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; + + for ($day = 0; $day < $days; $day++) { + $checkTs = $baseTs + ($day * 86400); + $activeHashrate = 0.0; + + foreach ($costPlans as $plan) { + if (empty($plan['is_active']) || !$this->entryIsCovered($plan, $checkTs)) { + continue; + } + + $activeHashrate += $this->normalizeHashrateMh($plan['mining_speed_value'] ?? null, $plan['mining_speed_unit'] ?? null); + $activeHashrate += $this->normalizeHashrateMh($plan['bonus_speed_value'] ?? null, $plan['bonus_speed_unit'] ?? null); + + $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); + if ($dailyCost !== null) { + $cost += $dailyCost; + } + } + } + + foreach ($purchasedMiners as $miner) { + if ((array_key_exists('is_active', $miner) && empty($miner['is_active'])) || !$this->entryIsCovered($miner, $checkTs, 'purchased_at')) { + continue; + } + + $activeHashrate += $this->normalizeHashrateMh($miner['mining_speed_value'] ?? null, $miner['mining_speed_unit'] ?? null); + $activeHashrate += $this->normalizeHashrateMh($miner['bonus_speed_value'] ?? null, $miner['bonus_speed_unit'] ?? null); + + $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); + if ($dailyCost !== null) { + $cost += $dailyCost; + } + } + } + + $revenue += $activeHashrate * $dogePerDayPerMh * $pricePerCoin; + } + + return [ + 'days' => $days, + 'revenue' => $revenue, + 'cost' => $cost, + 'profit' => $revenue - $cost, + ]; + } + + 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 0000000..8a8ef65 --- /dev/null +++ b/modules/mining-checker/src/Domain/FxService.php @@ -0,0 +1,759 @@ +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 + { + if ($amount === null || $from === null || $to === null) { + return null; + } + + $rate = $this->rate($from, $to); + return $rate === null ? null : $amount * $rate; + } + + public function rate(?string $from, ?string $to): ?float + { + $base = strtoupper(trim((string) $from)); + $target = strtoupper(trim((string) $to)); + + if ($base === '' || $target === '') { + return null; + } + + if ($base === $target) { + return 1.0; + } + + $cacheKey = $base . ':' . $target; + 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 refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array + { + $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 + { + $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) ? strtotime((string) ($latestFetch['fetched_at'] ?? '')) : false; + $ageSeconds = $latestFetchedAt ? (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 + { + $normalizedBase = strtoupper(trim($base)); + return $this->fetchLatestProbe($normalizedBase); + } + + public function refreshCurrencyCatalog(): array + { + if ($this->repository === null) { + return [ + 'synced_count' => 0, + 'currencies' => [], + ]; + } + + $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++; + } + + $this->repository->saveCurrencies($synced); + + 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 + { + 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 + { + if ($this->repository === null) { + return ['EUR', 'USD']; + } + + try { + $currencies = $this->repository->listActiveFiatCurrencies(); + return array_map(static fn (array $currency): string => (string) $currency['code'], $currencies); + } catch (\Throwable) { + 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 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; + } +} diff --git a/modules/mining-checker/src/Domain/OcrService.php b/modules/mining-checker/src/Domain/OcrService.php new file mode 100644 index 0000000..456177d --- /dev/null +++ b/modules/mining-checker/src/Domain/OcrService.php @@ -0,0 +1,400 @@ +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'))); + $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): array + { + $flags = []; + $suggestedTime = null; + $coinsTotal = null; + $price = null; + $currency = null; + $normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: ''; + + 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); + } + + $coinsCandidates = array_values(array_filter($decimalCandidates, static fn (array $item): bool => $item['value'] > 10 && $item['precision'] >= 4)); + 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'; + } + } + + $matchedFields = 0; + foreach ([$coinsTotal, $price, $currency] as $field) { + if ($field !== null) { + $matchedFields++; + } + } + + $confidence = max(0.05, min(0.99, ($matchedFields / 3) - (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, + ]; + } +} diff --git a/modules/mining-checker/src/Domain/SeedData.php b/modules/mining-checker/src/Domain/SeedData.php new file mode 100644 index 0000000..5f77826 --- /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 0000000..bc84e5a --- /dev/null +++ b/modules/mining-checker/src/Domain/SeedImporter.php @@ -0,0 +1,66 @@ +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()); + foreach (SeedData::currencies() as $currency) { + $this->repository->saveCurrency($currency); + } + $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, + 'currencies_synced' => count(SeedData::currencies()), + ]; + } +} diff --git a/modules/mining-checker/src/Infrastructure/ConnectionFactory.php b/modules/mining-checker/src/Infrastructure/ConnectionFactory.php new file mode 100644 index 0000000..1413f79 --- /dev/null +++ b/modules/mining-checker/src/Infrastructure/ConnectionFactory.php @@ -0,0 +1,38 @@ +useProjectDatabase()) { + throw new ApiException('Mining-Checker erwartet aktuell die Projekt-Datenbank. Eigene Modul-Datenbanken sind hier noch nicht implementiert.', 500); + } + + $dbConfig = app()->config()->dbConfig; + if ($dbConfig === []) { + throw new ApiException('Projekt-Datenbankkonfiguration fehlt in config/db_settings_basic.php.', 500); + } + + $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 in config/db_settings_basic.php den Driver auf mysql oder pgsql.', + 500, + ['driver' => $dbConfig['driver'] ?? 'unknown'] + ); + } + + if (method_exists(AppDatabase::class, 'connectFromConfig')) { + return AppDatabase::connectFromConfig($dbConfig); + } + + return AppDatabase::createFromArray($dbConfig); + } +} diff --git a/modules/mining-checker/src/Infrastructure/MiningRepository.php b/modules/mining-checker/src/Infrastructure/MiningRepository.php new file mode 100644 index 0000000..b7be03d --- /dev/null +++ b/modules/mining-checker/src/Infrastructure/MiningRepository.php @@ -0,0 +1,1400 @@ +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'] ?? 'Europe/Berlin', + '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 listCurrencies(): array + { + $this->debug?->add('db.listCurrencies.start'); + $stmt = $this->pdo->query( + 'SELECT * FROM ' . $this->table('currencies') . ' WHERE ' . ($this->driver === 'pgsql' ? 'is_active = TRUE' : 'is_active = 1') . ' ORDER BY sort_order ASC, code ASC' + ); + $rows = $this->normalizeRows($stmt->fetchAll() ?: []); + $this->debug?->add('db.listCurrencies.end', ['rows' => count($rows)]); + return $rows; + } + + public function listCurrencyAliases(): array + { + $stmt = $this->pdo->query( + 'SELECT + a.alias_code, + a.currency_code, + c.name AS currency_name, + a.created_at + FROM ' . $this->table('currency_aliases') . ' a + INNER JOIN ' . $this->table('currencies') . ' c ON c.code = a.currency_code + ORDER BY a.alias_code ASC' + ); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function resolveCurrencyCode(string $code): ?array + { + $normalizedCode = strtoupper(trim($code)); + if ($normalizedCode === '') { + return null; + } + + $currencyStmt = $this->pdo->prepare('SELECT * FROM ' . $this->table('currencies') . ' WHERE code = :code LIMIT 1'); + $currencyStmt->execute(['code' => $normalizedCode]); + $currency = $currencyStmt->fetch(); + if (is_array($currency)) { + return [ + 'input_code' => $normalizedCode, + 'code' => $normalizedCode, + 'matched_via' => 'code', + 'currency' => $this->normalizeRow($currency), + ]; + } + + if (!$this->tableExists('currency_aliases')) { + return null; + } + + $aliasStmt = $this->pdo->prepare( + 'SELECT + a.alias_code, + a.currency_code, + c.name AS currency_name, + c.symbol AS currency_symbol, + c.is_active, + c.sort_order + FROM ' . $this->table('currency_aliases') . ' a + INNER JOIN ' . $this->table('currencies') . ' c ON c.code = a.currency_code + WHERE a.alias_code = :alias_code + LIMIT 1' + ); + $aliasStmt->execute(['alias_code' => $normalizedCode]); + $alias = $aliasStmt->fetch(); + if (!is_array($alias)) { + return null; + } + + return [ + 'input_code' => $normalizedCode, + 'code' => (string) $alias['currency_code'], + 'matched_via' => 'alias', + 'alias_code' => (string) $alias['alias_code'], + 'currency' => $this->normalizeRow([ + 'code' => $alias['currency_code'], + 'name' => $alias['currency_name'], + 'symbol' => $alias['currency_symbol'] ?? null, + 'is_active' => $alias['is_active'] ?? 1, + 'sort_order' => $alias['sort_order'] ?? 1000, + ]), + ]; + } + + public function saveCurrencyAlias(string $aliasCode, string $currencyCode): array + { + $normalizedAlias = strtoupper(trim($aliasCode)); + $normalizedCurrency = strtoupper(trim($currencyCode)); + + $stmt = $this->pdo->prepare($this->driver === 'pgsql' + ? 'INSERT INTO ' . $this->table('currency_aliases') . ' (alias_code, currency_code) + VALUES (:alias_code, :currency_code) + ON CONFLICT (alias_code) DO UPDATE SET currency_code = EXCLUDED.currency_code + RETURNING *' + : 'INSERT INTO ' . $this->table('currency_aliases') . ' (alias_code, currency_code) + VALUES (:alias_code, :currency_code) + ON DUPLICATE KEY UPDATE currency_code = VALUES(currency_code)' + ); + $stmt->execute([ + 'alias_code' => $normalizedAlias, + 'currency_code' => $normalizedCurrency, + ]); + + if ($this->driver === 'pgsql') { + $row = $stmt->fetch(); + return is_array($row) ? $this->normalizeRow($row) : ['alias_code' => $normalizedAlias, 'currency_code' => $normalizedCurrency]; + } + + return ['alias_code' => $normalizedAlias, 'currency_code' => $normalizedCurrency]; + } + + public function saveCurrency(array $currency): void + { + $this->debug?->add('db.saveCurrency.start', ['code' => $currency['code'] ?? null]); + $stmt = $this->pdo->prepare($this->driver === 'pgsql' + ? 'INSERT INTO ' . $this->table('currencies') . ' (code, name, symbol, is_active, is_crypto, sort_order) + VALUES (:code, :name, :symbol, :is_active, :is_crypto, :sort_order) + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + symbol = EXCLUDED.symbol, + is_active = EXCLUDED.is_active, + is_crypto = EXCLUDED.is_crypto, + sort_order = EXCLUDED.sort_order' + : 'INSERT INTO ' . $this->table('currencies') . ' (code, name, symbol, is_active, is_crypto, sort_order) + VALUES (:code, :name, :symbol, :is_active, :is_crypto, :sort_order) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + symbol = VALUES(symbol), + is_active = VALUES(is_active), + is_crypto = VALUES(is_crypto), + sort_order = VALUES(sort_order)' + ); + $stmt->execute([ + 'code' => $currency['code'], + 'name' => $currency['name'], + 'symbol' => $currency['symbol'], + 'is_active' => $currency['is_active'], + 'is_crypto' => $currency['is_crypto'] ?? 0, + 'sort_order' => $currency['sort_order'], + ]); + $this->debug?->add('db.saveCurrency.end', ['code' => $currency['code'] ?? null]); + } + + public function saveCurrencies(array $currencies): int + { + if ($currencies === []) { + return 0; + } + + $this->debug?->add('db.saveCurrencies.start', ['count' => count($currencies)]); + + $statement = $this->pdo->prepare($this->driver === 'pgsql' + ? 'INSERT INTO ' . $this->table('currencies') . ' (code, name, symbol, is_active, is_crypto, sort_order) + VALUES (:code, :name, :symbol, :is_active, :is_crypto, :sort_order) + ON CONFLICT (code) DO UPDATE SET + name = EXCLUDED.name, + symbol = EXCLUDED.symbol, + is_active = EXCLUDED.is_active, + is_crypto = EXCLUDED.is_crypto, + sort_order = EXCLUDED.sort_order' + : 'INSERT INTO ' . $this->table('currencies') . ' (code, name, symbol, is_active, is_crypto, sort_order) + VALUES (:code, :name, :symbol, :is_active, :is_crypto, :sort_order) + ON DUPLICATE KEY UPDATE + name = VALUES(name), + symbol = VALUES(symbol), + is_active = VALUES(is_active), + is_crypto = VALUES(is_crypto), + sort_order = VALUES(sort_order)' + ); + + $count = 0; + $startedTransaction = false; + if (!$this->pdo->inTransaction()) { + $this->pdo->beginTransaction(); + $startedTransaction = true; + } + + try { + foreach ($currencies as $currency) { + if (!is_array($currency) || empty($currency['code'])) { + continue; + } + + $statement->execute([ + 'code' => $currency['code'], + 'name' => $currency['name'] ?? $currency['code'], + 'symbol' => $currency['symbol'] ?? $currency['code'], + 'is_active' => $currency['is_active'] ?? 1, + 'is_crypto' => $currency['is_crypto'] ?? 0, + 'sort_order' => $currency['sort_order'] ?? 1000, + ]); + $count++; + } + + if ($startedTransaction) { + $this->pdo->commit(); + } + $this->debug?->add('db.saveCurrencies.end', ['count' => $count]); + } catch (\Throwable $exception) { + if ($startedTransaction && $this->pdo->inTransaction()) { + $this->pdo->rollBack(); + } + $this->debug?->add('db.saveCurrencies.error', ['message' => $exception->getMessage()]); + throw $exception; + } + + return $count; + } + + public function ensureCurrencyCode(string $code, ?string $name = null): void + { + $normalizedCode = strtoupper(trim($code)); + if ($normalizedCode === '') { + return; + } + + $this->saveCurrency([ + 'code' => substr($normalizedCode, 0, 10), + 'name' => $name !== null && trim($name) !== '' ? trim($name) : $normalizedCode, + 'symbol' => substr($normalizedCode, 0, 8), + 'is_active' => 1, + 'is_crypto' => $this->isCryptoCode($normalizedCode) ? 1 : 0, + 'sort_order' => 1000, + ]); + } + + 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 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, + ]); + $params = [ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'measured_at' => $payload['measured_at'], + 'coins_total' => $payload['coins_total'], + 'price_per_coin' => $payload['price_per_coin'], + 'price_currency' => $payload['price_currency'], + '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, price_per_coin, price_currency, note, + source, image_path, ocr_raw_text, ocr_confidence, ocr_flags + ) VALUES ( + :project_key, :owner_sub, :measured_at, :coins_total, :price_per_coin, :price_currency, :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, price_per_coin, price_currency, note, + source, image_path, ocr_raw_text, ocr_confidence, ocr_flags + ) VALUES ( + :project_key, :owner_sub, :measured_at, :coins_total, :price_per_coin, :price_currency, :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 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 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 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 = []; + $currenciesToEnsure = [ + [ + 'code' => substr($baseCurrency, 0, 10), + 'name' => $baseCurrency, + 'symbol' => substr($baseCurrency, 0, 8), + 'is_active' => 1, + 'is_crypto' => $this->isCryptoCode($baseCurrency) ? 1 : 0, + 'sort_order' => 1000, + ], + ]; + + foreach ($rates as $currencyCode => $rate) { + if (!is_numeric($rate)) { + continue; + } + + $normalizedCurrencyCode = strtoupper(trim((string) $currencyCode)); + if ($normalizedCurrencyCode === '' || $normalizedCurrencyCode === $baseCurrency) { + continue; + } + + $normalizedRates[$normalizedCurrencyCode] = (float) $rate; + $currenciesToEnsure[] = [ + 'code' => substr($normalizedCurrencyCode, 0, 10), + 'name' => $normalizedCurrencyCode, + 'symbol' => substr($normalizedCurrencyCode, 0, 8), + 'is_active' => 1, + 'is_crypto' => $this->isCryptoCode($normalizedCurrencyCode) ? 1 : 0, + 'sort_order' => 1000, + ]; + } + + $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 { + $this->debug?->add('db.saveFxFetch.saveCurrencies.start', [ + 'currency_count' => count($currenciesToEnsure), + ]); + $this->saveCurrencies($currenciesToEnsure); + $this->debug?->add('db.saveFxFetch.saveCurrencies.end', [ + 'currency_count' => count($currenciesToEnsure), + ]); + + 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; + } + } + + public function listActiveFiatCurrencies(): array + { + $currencies = $this->listCurrencies(); + + return array_values(array_filter($currencies, static function (array $currency): bool { + $code = strtoupper((string) ($currency['code'] ?? '')); + return $code !== '' && empty($currency['is_crypto']); + })); + } + + private function table(string $logicalName): string + { + return match ($logicalName) { + 'projects' => $this->prefix . 'projects', + 'currencies' => $this->prefix . 'currencies', + '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', + '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'] 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 0000000..261f072 --- /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 0000000..c76993b --- /dev/null +++ b/modules/mining-checker/src/Infrastructure/SchemaManager.php @@ -0,0 +1,1265 @@ +pdo = $pdo; + $this->prefix = $prefix; + $this->moduleBasePath = rtrim($moduleBasePath, '/'); + $this->driver = strtolower((string) $this->pdo->getAttribute(PDO::ATTR_DRIVER_NAME)); + } + + 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->prefix . 'projects', + $this->prefix . 'currencies', + $this->prefix . 'settings', + $this->prefix . 'cost_plans', + $this->prefix . 'measurements', + $this->prefix . 'targets', + $this->prefix . 'dashboard_definitions', + ]; + + $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 . 'measurement_rates')) { + $this->ensureMeasurementRatesTable(); + $applied[] = 'measurement_rates_table'; + } + if (!$this->tableExists($this->prefix . 'payouts')) { + $this->ensurePayoutsTable(); + $applied[] = 'payouts_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 . 'currency_aliases')) { + $this->ensureCurrencyAliasesTable(); + $applied[] = 'currency_aliases_table'; + } + if ($this->tableExists($this->prefix . 'currencies') && !$this->columnExists($this->prefix . 'currencies', 'is_crypto')) { + $this->upgradeCurrenciesClassificationColumns(); + $applied[] = 'currency_classification'; + } + if ($this->tableExists($this->prefix . 'currencies')) { + $this->ensureCurrencyForeignKeys(); + $applied[] = 'currency_foreign_keys'; + } + 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 . 'currencies', + $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 . '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 . '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 . 'currency_aliases')) { + $this->ensureCurrencyAliasesTable(); + $applied[] = 'currency_aliases_table'; + } + if ($this->tableExists($this->prefix . 'currencies') && !$this->columnExists($this->prefix . 'currencies', 'is_crypto')) { + $this->upgradeCurrenciesClassificationColumns(); + $applied[] = 'currency_classification'; + } + if ($this->tableExists($this->prefix . 'currencies')) { + $this->ensureCurrencyForeignKeys(); + $applied[] = 'currency_foreign_keys'; + } + 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.', + ]; + } + + 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); + $statements = preg_split('/;\s*(?:\R|$)/', $sql) ?: []; + + try { + foreach ($statements as $statement) { + $trimmed = trim($statement); + if ($trimmed === '') { + continue; + } + $this->pdo->exec($trimmed); + } + } catch (\Throwable $exception) { + throw new ApiException( + 'Schema-Import fuer Mining-Checker fehlgeschlagen.', + 500, + ['message' => $exception->getMessage()] + ); + } + } + + 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 . '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 . '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(); + $this->ensureCurrencyAliasesTable(); + } + + public function ensureCurrencyAliasesTable(): void + { + if ($this->tableExists($this->prefix . 'currency_aliases')) { + return; + } + + $table = $this->prefix . 'currency_aliases'; + $currencyTable = $this->prefix . 'currencies'; + $statements = $this->driver === 'pgsql' + ? [ + 'CREATE TABLE IF NOT EXISTS ' . $table . ' ( + alias_code VARCHAR(10) PRIMARY KEY, + currency_code VARCHAR(10) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_currency_aliases_currency FOREIGN KEY (currency_code) REFERENCES ' . $currencyTable . '(code) ON DELETE CASCADE + )', + ] + : [ + 'CREATE TABLE IF NOT EXISTS `' . $table . '` ( + alias_code VARCHAR(10) NOT NULL PRIMARY KEY, + currency_code VARCHAR(10) NOT NULL, + created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP, + CONSTRAINT fk_mining_currency_aliases_currency FOREIGN KEY (currency_code) REFERENCES `' . $currencyTable . '`(code) ON DELETE CASCADE + )', + ]; + + $this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer Waehrungs-Aliase fehlgeschlagen.'); + } + + 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 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 . 'currencies', + $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 . 'miner_offers', + $this->prefix . 'purchased_miners', + $this->prefix . 'currency_aliases', + ]; + } + + private function knownTablesInDropOrder(): array + { + $allTables = array_merge($this->coreTables(), $this->extraTables()); + return array_reverse($allTables); + } +} diff --git a/modules/mining-checker/src/Support/ApiException.php b/modules/mining-checker/src/Support/ApiException.php new file mode 100644 index 0000000..665ac02 --- /dev/null +++ b/modules/mining-checker/src/Support/ApiException.php @@ -0,0 +1,29 @@ +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 0000000..7ccda2b --- /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 0000000..e90d3bb --- /dev/null +++ b/modules/mining-checker/src/Support/Http.php @@ -0,0 +1,27 @@ + (string)($claims['sub'] ?? ''), 'email' => (string)($claims['email'] ?? ''), 'name' => (string)($claims['name'] ?? ($claims['preferred_username'] ?? '')), + 'username' => (string)($claims['preferred_username'] ?? $claims['email'] ?? $claims['sub'] ?? ''), 'groups' => $groups, 'id_token' => $idToken, ]; -$_SESSION['auth_user'] = $user; +app()->auth()->storeUser($claims, $groups, $idToken); if (defined('APP_AUTH_DEBUG') && APP_AUTH_DEBUG) { $log = [ @@ -77,4 +78,6 @@ if (defined('APP_AUTH_DEBUG') && APP_AUTH_DEBUG) { @file_put_contents(__DIR__ . '/../../../debug/oidc_login.log', json_encode($log) . PHP_EOL, FILE_APPEND); } -redirect('/'); +$returnTo = (string)($_SESSION['oidc_return_to'] ?? '/'); +unset($_SESSION['oidc_return_to']); +redirect($returnTo !== '' && str_starts_with($returnTo, '/') && !str_starts_with($returnTo, '//') ? $returnTo : '/'); diff --git a/partials/landingpages/auth/logout.php b/partials/landingpages/auth/logout.php index 56d5948..aebdd0c 100644 --- a/partials/landingpages/auth/logout.php +++ b/partials/landingpages/auth/logout.php @@ -10,7 +10,7 @@ if (!empty($_SESSION['auth_user']['id_token'])) { $idToken = (string)$_SESSION['auth_user']['id_token']; } -unset($_SESSION['auth_user']); +unset($_SESSION['auth_user'], $_SESSION['auth_id_token'], $_SESSION['auth_expires_at']); if ($config->authEnabled) { $client = new OidcClient($config); diff --git a/partials/landingpages/index.php b/partials/landingpages/index.php index f29ab3e..7fb2557 100755 --- a/partials/landingpages/index.php +++ b/partials/landingpages/index.php @@ -1,34 +1,76 @@ all(); +declare(strict_types=1); + +$auth = app()->auth(); +$authUser = $auth->user(); +$modules = array_values(array_filter( + $auth->filterModules(modules()->all()), + static fn (array $module): bool => !empty($module['enabled']) +)); ?> -
-
Core
-

Nexus Basis-System

-

Aktive Module verwalten und neue Module initialisieren.

+
+ + Kusche Logo + +
+ Nexus +

+

Kompakter Einstieg fuer die verfuegbaren Module.

+
+
+ isEnabled()): ?> + + + + + + +
+
-
- Module verwalten +
+
+
+

Verfuegbare Module

+

Module mit Login-Pflicht erscheinen erst nach passender Anmeldung.

+
+ + Module verwalten +
-
- -
-
-
- -
-
- - aktiv - - inaktiv - -
-
- Öffnen - Setup -
-
- -
-
+ +
+ Keine Module fuer den aktuellen Zugriff sichtbar. +
+ + + + diff --git a/partials/landingpages/modules/setup.php b/partials/landingpages/modules/setup.php index 9a44e31..d925a05 100644 --- a/partials/landingpages/modules/setup.php +++ b/partials/landingpages/modules/setup.php @@ -69,9 +69,16 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { } modules()->saveSettings($moduleName, $payload); + modules()->saveAuth($moduleName, [ + 'required' => isset($_POST['auth_required']), + 'users' => (string)($_POST['auth_users'] ?? ''), + 'groups' => (string)($_POST['auth_groups'] ?? ''), + ]); $notice = 'Setup gespeichert.'; $current = array_replace_recursive($current, $payload); + $module = modules()->get($moduleName) ?: $module; } +$authConfig = is_array($module['auth'] ?? null) ? $module['auth'] : ['required' => false, 'users' => [], 'groups' => []]; ?>
Setup
@@ -122,6 +129,23 @@ if ($_SERVER['REQUEST_METHOD'] === 'POST') { +
+ Modulzugriff + + + + Wenn Login aktiv ist und Benutzer/Gruppen leer bleiben, darf jeder eingeloggte Benutzer das Modul oeffnen. +
+
Zurück diff --git a/partials/structure/layout_start.php b/partials/structure/layout_start.php index de46405..5945973 100755 --- a/partials/structure/layout_start.php +++ b/partials/structure/layout_start.php @@ -19,7 +19,7 @@ $sidebarDefault = ($moduleSidebar['default'] ?? 'collapsed') === 'open' ? 'open' $sidebarItems = $moduleSidebar['items'] ?? []; ?> - + @@ -27,7 +27,7 @@ $sidebarItems = $moduleSidebar['items'] ?? []; - +
@@ -40,7 +40,7 @@ $sidebarItems = $moduleSidebar['items'] ?? [];