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.
+
+
+
+
+
+
Nexus
+
= e(defined('APP_DOMAIN_PRIMARY') ? (string)APP_DOMAIN_PRIMARY : 'Nexus') ?>
+
Kompakter Einstieg fuer die verfuegbaren Module.
+
+
+
-
-
Module verwalten
+
+
+
+
Verfuegbare Module
+
Module mit Login-Pflicht erscheinen erst nach passender Anmeldung.
+
+
+
Module verwalten
+
-
-
-
-
-
-
= e($module['title']) ?>
-
= e($module['description'] ?? '') ?>
-
-
-
aktiv
-
-
inaktiv
-
-
-
-
-
-
-
+
+
+ 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'] ?? [];