mining checker
All checks were successful
Deploy / deploy-staging (push) Successful in 27s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-06-21 00:14:19 +02:00
parent 2cdd14c400
commit b81de785ac
63 changed files with 11549 additions and 1338 deletions

View File

@@ -4,6 +4,17 @@ Klassische Module bleiben in diesem Projekt unter `modules/<modul>/`.
Die neue Desktop-Shell ist nur die UI-Schicht. Modul-Businesslogik wird nicht in den Desktop-Core verschoben.
## Aktuelle Modulstruktur
Ein Desktop-Modul kann zusaetzlich diese Projektdateien besitzen:
- `desktop.php` fuer die Desktop-App-Metadaten und Asset-Definitionen
- `pages/` fuer Standalone-Seiten oder iframe/native Einstiegspunkte
- `api/` fuer modulinterne HTTP-Endpunkte
- `assets/` fuer modulnahe CSS- und JS-Dateien
Module-Assets werden in diesem Projekt nicht direkt aus `public/assets/apps/...` dupliziert, sondern ueber den generischen Endpoint `/module-assets/index.php` aus dem jeweiligen Modul ausgeliefert.
## Pflegehinweis
Diese Datei muss gepflegt bleiben, wenn sich Modulstruktur oder Modulregeln aendern.

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__) . '/bootstrap.php';
$requestUri = (string) ($_SERVER['REQUEST_URI'] ?? '');
$requestPath = (string) (parse_url($requestUri, PHP_URL_PATH) ?: '');
$prefix = '/api/mining-checker/index.php/';
$relativePath = trim((string) ($_GET['path'] ?? ''), '/');
if ($relativePath === '') {
$relativePath = str_starts_with($requestPath, $prefix)
? substr($requestPath, strlen($prefix))
: ltrim((string) ($_SERVER['PATH_INFO'] ?? ''), '/');
}
(new Modules\MiningChecker\Api\Router(dirname(__DIR__)))->handle($relativePath);

View File

@@ -0,0 +1,772 @@
#mining-checker-app {
--mc-surface: var(--surface);
--mc-surface-strong: linear-gradient(180deg, rgba(255,255,255,0.96), rgba(248,252,252,0.92));
--mc-line: var(--line);
--mc-line-strong: color-mix(in srgb, var(--brand-accent) 28%, transparent);
--mc-text: var(--text);
--mc-text-muted: var(--muted);
--mc-accent: var(--brand-accent);
--mc-accent-strong: var(--brand-accent-3);
--mc-danger: #d92d20;
--mc-success: #14804a;
--mc-warning: #b54708;
min-height: 0;
background: none;
color: var(--mc-text);
font-family: inherit;
max-width: 100%;
overflow-x: clip;
}
#mining-checker-app,
#mining-checker-app * {
box-sizing: border-box;
}
#mining-checker-app .mc-grid-bg {
background: none;
max-width: 100%;
overflow-x: clip;
}
#mining-checker-app .mc-shell {
width: 100%;
max-width: 100%;
margin: 0;
padding: 0;
}
#mining-checker-app .mc-stack {
display: grid;
gap: 16px;
}
#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: 22px;
background: var(--mc-surface);
/* box-shadow: 0 12px 30px rgba(1, 22, 32, 0.08); */
backdrop-filter: blur(8px);
}
#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: 18px 20px;
}
#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-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-asset-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
gap: 14px;
}
#mining-checker-app .mc-asset-card {
gap: 8px;
}
#mining-checker-app .mc-asset-balance {
font-size: 1.15rem;
font-weight: 700;
color: var(--mc-text);
}
#mining-checker-app .mc-asset-list {
display: grid;
gap: 10px;
min-width: 240px;
}
#mining-checker-app .mc-asset-row {
display: grid;
gap: 2px;
}
#mining-checker-app .mc-asset-row strong {
color: var(--mc-text);
}
#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-stat-card {
background: var(--mc-surface-strong);
}
#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: var(--mc-text);
}
#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: color-mix(in srgb, var(--mc-warning) 12%, transparent);
color: var(--mc-warning);
}
#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: color-mix(in srgb, var(--mc-danger) 12%, transparent);
color: var(--mc-danger);
}
#mining-checker-app .mc-badge--success {
background: color-mix(in srgb, var(--mc-success) 12%, transparent);
color: var(--mc-success);
}
#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: var(--mc-text);
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-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: color-mix(in srgb, var(--brand-accent-2) 8%, var(--mc-surface));
}
#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.72);
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-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-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-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-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;
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,17 @@
<?php
declare(strict_types=1);
spl_autoload_register(static function (string $class): void {
$prefix = 'Modules\\MiningChecker\\';
if (!str_starts_with($class, $prefix)) {
return;
}
$relativeClass = substr($class, strlen($prefix));
$file = __DIR__ . '/src/' . str_replace('\\', '/', $relativeClass) . '.php';
if (is_file($file)) {
require_once $file;
}
});

View File

@@ -0,0 +1,24 @@
<?php
declare(strict_types=1);
return [
'MINING_CHECKER_DEFAULT_PROJECT_KEY' => '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',
];

View File

@@ -0,0 +1,46 @@
<?php
declare(strict_types=1);
return [
'default_project_key' => 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',
],
];

View File

@@ -0,0 +1,17 @@
{
"eyebrow": "Modul",
"title": "Mining-Checker",
"description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.",
"actions": [
{ "label": "Nexus Übersicht", "href": "/modules" },
{ "label": "Setup", "href": "/modules/setup/mining-checker", "variant": "secondary" }
],
"sections": [
{ "key": "overview", "label": "Übersicht" },
{ "key": "upload", "label": "Upload" },
{ "key": "measurements", "label": "Mining-History" },
{ "key": "wallet", "label": "Wallet" },
{ "key": "mining", "label": "Miner-Daten" },
{ "key": "dashboards", "label": "Dashboards" }
]
}

View File

@@ -0,0 +1,57 @@
<?php
declare(strict_types=1);
$manifest = [];
$manifestRaw = is_file(__DIR__ . '/module.json') ? file_get_contents(__DIR__ . '/module.json') : false;
if ($manifestRaw !== false) {
$decoded = json_decode($manifestRaw, true);
$manifest = is_array($decoded) ? $decoded : [];
}
$design = [];
$designRaw = is_file(__DIR__ . '/design.json') ? file_get_contents(__DIR__ . '/design.json') : false;
if ($designRaw !== false) {
$decoded = json_decode($designRaw, true);
$design = is_array($decoded) ? $decoded : [];
}
$sections = array_values(array_filter(
is_array($design['sections'] ?? null) ? $design['sections'] : [],
static fn (mixed $section): bool => is_array($section)
));
return [
'app_id' => 'mining-checker',
'title' => (string) ($manifest['title'] ?? 'Mining-Checker'),
'icon' => 'MC',
'entry_route' => '/apps/mining-checker',
'window_mode' => 'window',
'content_mode' => 'native-module',
'default_width' => 1180,
'default_height' => 760,
'supports_widget' => false,
'supports_tray' => true,
'module_name' => 'finance',
'summary' => (string) ($manifest['description'] ?? 'Vollstaendiger Rechner fuer Mining-Profitabilitaet, Stromkosten, ROI und Snapshot-Historie.'),
'native_module' => [
'initializer' => 'initMiningCheckerApp',
'options' => [
'apiBase' => '/api/mining-checker/index.php/v1',
'defaultProjectKey' => getenv('MINING_CHECKER_DEFAULT_PROJECT_KEY') ?: 'doge-main',
'activeView' => 'overview',
'sections' => $sections,
'moduleDebugEnabled' => false,
],
'assets' => [
'styles' => [
['href' => '/module-assets/index.php?module=mining-checker&path=assets/css/app.css'],
],
'scripts' => [
['src' => 'https://unpkg.com/react@18/umd/react.production.min.js', 'crossorigin' => true],
['src' => 'https://unpkg.com/react-dom@18/umd/react-dom.production.min.js', 'crossorigin' => true],
['src' => '/module-assets/index.php?module=mining-checker&path=assets/js/app.js'],
],
],
],
];

View File

@@ -0,0 +1,114 @@
# Mining-Checker Modul
## Zweck
Das Modul erfasst DOGE-Mining-Messpunkte, analysiert OCR-Vorschlaege aus Screenshots, speichert Messreihen projektbezogen und berechnet Performance-, Kurs- und Zielmetriken.
## Ordnerstruktur
```text
modules/mining-checker/
|-- api/
|-- assets/
| |-- css/
| `-- js/
|-- config/
|-- docs/
|-- pages/
|-- partials/
|-- sql/
| `-- migrations/
|-- src/
| |-- Api/
| |-- Domain/
| |-- Infrastructure/
| `-- Support/
|-- storage/uploads/
|-- bootstrap.php
`-- module.json
```
## API-Endpunkte
- `GET /api/mining-checker/v1/health`
- `GET /api/mining-checker/v1/projects/{projectKey}/bootstrap`
- `GET /api/mining-checker/v1/projects/{projectKey}/measurements`
- `POST /api/mining-checker/v1/projects/{projectKey}/measurements`
- `POST /api/mining-checker/v1/projects/{projectKey}/ocr-preview`
- `GET /api/mining-checker/v1/projects/{projectKey}/settings`
- `PUT /api/mining-checker/v1/projects/{projectKey}/settings`
- `GET /api/mining-checker/v1/projects/{projectKey}/targets`
- `POST /api/mining-checker/v1/projects/{projectKey}/targets`
- `PATCH /api/mining-checker/v1/projects/{projectKey}/targets/{targetId}`
- `GET /api/mining-checker/v1/projects/{projectKey}/dashboards`
- `POST /api/mining-checker/v1/projects/{projectKey}/dashboards`
- `GET /api/mining-checker/v1/projects/{projectKey}/dashboard-data`
- `POST /api/mining-checker/v1/projects/{projectKey}/seed-import`
- `GET /api/mining-checker/v1/projects/{projectKey}/schema-status`
- `POST /api/mining-checker/v1/projects/{projectKey}/initialize`
- `POST /api/mining-checker/v1/projects/{projectKey}/upgrade`
- `GET /api/mining-checker/v1/projects/{projectKey}/connection-test`
- `GET /api/mining-checker/v1/projects/{projectKey}/fx-history`
- `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate`
## Integration
1. SQL aus dem passenden Dialekt-Schema ausfuehren:
- MySQL/MariaDB: `sql/schema.mysql.sql`
- PostgreSQL: `sql/schema.pgsql.sql`
- `sql/schema.sql` bleibt der Rueckfall fuer bestehende Setups
2. Das Modul nutzt bewusst dieselbe Projekt-Datenbank wie die Anwendung und legt seine Tabellen mit dem Praefix `miningcheck_` an.
3. Modulroute ueber `/module/mining-checker` aufrufen.
4. REST-API wird ueber `/api/mining-checker/...` vom Hauptprojekt geroutet.
Hinweis:
Wenn beim ersten API-Zugriff noch keine `miningcheck_*` Tabellen vorhanden sind, importiert das Modul automatisch das zum aktiven PDO-Treiber passende Schema.
Seed-Daten werden dabei nicht automatisch eingespielt.
Fuer eine manuelle Initialisierung, ein inkrementelles Upgrade oder einen Reset gibt es zusaetzlich `schema-status`, `upgrade` und `initialize`. Mit `{ "drop_existing": true }` werden vorhandene `miningcheck_*` Tabellen inklusive Daten geloescht und das Schema neu angelegt.
## OCR-Hinweis
Das Modul unterstuetzt einen OCR-Provider-Stack. Standardmaessig wird zuerst `ocr.space` verwendet und danach optional auf lokales `tesseract` zurueckgefallen.
Empfohlene Umgebungsvariablen:
- `MINING_CHECKER_OCR_PROVIDERS=ocrspace,tesseract`
- `MINING_CHECKER_OCR_SPACE_URL=https://api.ocr.space/parse/image`
- `MINING_CHECKER_OCR_SPACE_API_KEY=...`
- `MINING_CHECKER_OCR_SPACE_LANGUAGE=eng`
- `MINING_CHECKER_OCR_SPACE_ENGINE=2`
- `MINING_CHECKER_OCR_SPACE_SCALE=true`
- `MINING_CHECKER_OCR_SPACE_DETECT_ORIENTATION=true`
- `MINING_CHECKER_OCR_SPACE_IS_TABLE=false`
- `MINING_CHECKER_OCR_SPACE_TIMEOUT=25`
- `MINING_CHECKER_TESSERACT_BIN=/usr/bin/tesseract`
- `MINING_CHECKER_TESSERACT_LANG=eng`
Laut OCR.space-Doku wird `POST https://api.ocr.space/parse/image` mit `file`, Header-`apikey`, optional `language`, `scale`, `detectOrientation`, `isTable` und `OCREngine` verwendet. Der Modulparser wertet die OCR.space-Felder `ParsedResults`, `ParsedText`, `IsErroredOnProcessing`, `ErrorMessage` und `OCRExitCode` aus. Quellen: https://ocr.space/ocrapi
## Wechselkurse und Waehrungen
Der Mining-Checker speichert keine eigenen FX-Snapshots mehr, sondern referenziert die `fetch_id` aus `fx-rates`.
Auch der Waehrungskatalog und die bevorzugten Waehrungen kommen ausschliesslich aus `fx-rates`. Der Mining-Checker fuehrt keine eigene Waehrungstabelle und keine eigene Alias-Verwaltung mehr im Laufzeitpfad.
- `MINING_CHECKER_FX_AUTO_FETCH_ON_MISS=false`
Optionaler JSON-Body:
- `base`: Standard `EUR`
- `symbols`: wird aktuell ignoriert; der Mining-Checker speichert immer den kompletten Waehrungssatz des Fetches
Beispiel:
```json
{
"base": "EUR"
}
```
`currencyapi.net` wird ueber das Modul `fx-rates` abgefragt. Aus dem Response werden `base`, `rates` und `updated` uebernommen; `valid` muss `true` sein. Die eigentlichen Fetches und Raten liegen im Modul `fx-rates`.
Pro Abruf entsteht genau ein Datensatz in `fx-rates` mit Basiswaehrung, Provider und Stichtag. Neue Mining-Messpunkte pruefen beim Speichern, ob ein neuer FX-Fetch noetig ist; falls nicht, wird die letzte passende `fetch_id` wiederverwendet.
Falls noch historische Mining-Checker-Fetches in `miningcheck_fx_fetches` und `miningcheck_fx_rates` liegen, kann `POST /api/mining-checker/v1/projects/{projectKey}/legacy-fx-migrate` diese nach `fx-rates` ueberfuehren. Danach werden bestehende Messpunkte soweit moeglich auf die passende `fx_fetch_id` aktualisiert.
Fuer Auswertungen, Berichte und Listen speichert der Mining-Checker pro Messpunkt die damals passende `fx_fetch_id`. Historische Umrechnungen laufen damit gegen genau den zugeordneten `fx-rates`-Snapshot.

View File

@@ -0,0 +1,30 @@
{
"name": "Mining-Checker",
"title": "Mining-Checker",
"version": "0.3.0",
"description": "Erfassung, OCR-Auswertung und Analyse von DOGE-Mining-Messwerten als eingebettetes Modul.",
"enabled_by_default": true,
"setup": {
"sections": {
"database": true
},
"fields": [
{ "name": "use_separate_db", "label": "Eigene Modul-DB nutzen", "type": "checkbox", "required": false, "help": "Wenn aktiv, werden die DB-Daten unten verwendet. Sonst wird die Nexus-Base-DB genutzt." },
{ "name": "db.driver", "label": "DB Driver", "type": "text", "required": false, "help": "z.B. pgsql oder mysql" },
{ "name": "db.host", "label": "DB Host", "type": "text", "required": false },
{ "name": "db.port", "label": "DB Port", "type": "number", "required": false },
{ "name": "db.dbname", "label": "DB Name", "type": "text", "required": false },
{ "name": "db.schema", "label": "DB Schema", "type": "text", "required": false },
{ "name": "db.user", "label": "DB User", "type": "text", "required": false },
{ "name": "db.password", "label": "DB Passwort", "type": "password", "required": false },
{ "name": "baseline_measured_at", "label": "Baseline Zeitpunkt", "type": "datetime-local", "required": false, "help": "Referenzzeitpunkt fuer die erste Baseline-Messung." },
{ "name": "baseline_coins_total", "label": "Baseline Coins", "type": "number", "required": false, "help": "Coin-Bestand zum Baseline-Zeitpunkt." },
{ "name": "report_currency", "label": "Standard-Berichtswaehrung", "type": "text", "required": false, "help": "Zielwaehrung fuer Kennzahlen und Berichte, z.B. EUR." }
]
},
"auth": {
"required": true,
"users": [],
"groups": []
}
}

View File

@@ -0,0 +1,97 @@
<?php
declare(strict_types=1);
require_once dirname(__DIR__, 3) . '/src/App/bootstrap.php';
require_once dirname(__DIR__) . '/bootstrap.php';
use ModulesCore\ModuleHttp;
session_start();
$projectRoot = dirname(__DIR__, 3);
ModuleHttp::requireDesktopAccess($projectRoot);
$design = [];
$designRaw = is_file(dirname(__DIR__) . '/design.json') ? file_get_contents(dirname(__DIR__) . '/design.json') : false;
if ($designRaw !== false) {
$decoded = json_decode($designRaw, true);
$design = is_array($decoded) ? $decoded : [];
}
$moduleConfig = require dirname(__DIR__) . '/config/module.php';
$defaultProjectKey = (string) ($moduleConfig['default_project_key'] ?? 'doge-main');
$sections = is_array($design['sections'] ?? null) ? $design['sections'] : [];
$activeView = trim((string) ($_GET['view'] ?? 'overview'));
$sectionKeys = array_values(array_filter(array_map(
static fn (mixed $section): string => is_array($section) ? trim((string) ($section['key'] ?? '')) : '',
$sections
)));
if ($sectionKeys === []) {
$sectionKeys = ['overview'];
}
if (!in_array($activeView, $sectionKeys, true)) {
$activeView = $sectionKeys[0];
}
$sectionsJson = json_encode($sections, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
?><!DOCTYPE html>
<html lang="de">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>Mining-Checker</title>
<style>
:root {
color-scheme: light;
--surface: rgba(255, 255, 255, 0.92);
--line: rgba(148, 163, 184, 0.24);
--text: #0f172a;
--muted: #475569;
--brand-accent: #14b8a6;
--brand-accent-3: #0f766e;
font-family: "IBM Plex Sans", "Segoe UI", sans-serif;
}
* {
box-sizing: border-box;
}
html,
body {
min-height: 100%;
}
body {
margin: 0;
background:
radial-gradient(circle at top left, rgba(45, 212, 191, 0.12), transparent 24%),
linear-gradient(180deg, #f8fafc 0%, #eef2f7 100%);
color: var(--text);
}
.mining-module-shell {
min-height: 100vh;
padding: 18px;
}
</style>
<link rel="stylesheet" href="/module-assets/index.php?module=mining-checker&amp;path=assets/css/app.css">
</head>
<body data-module-debug-enabled="0">
<div class="mining-module-shell">
<div
id="mining-checker-app"
data-default-project-key="<?= htmlspecialchars($defaultProjectKey, ENT_QUOTES) ?>"
data-api-base="/api/mining-checker/index.php/v1"
data-active-view="<?= htmlspecialchars($activeView, ENT_QUOTES) ?>"
data-sections-json="<?= htmlspecialchars(is_string($sectionsJson) ? $sectionsJson : '[]', ENT_QUOTES) ?>"
></div>
</div>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="/module-assets/index.php?module=mining-checker&amp;path=assets/js/app.js"></script>
</body>
</html>

View File

View File

@@ -0,0 +1,124 @@
CREATE TABLE IF NOT EXISTS miningcheck_projects (
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
project_name VARCHAR(160) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS miningcheck_settings (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
baseline_measured_at DATETIME NOT NULL,
baseline_coins_total DECIMAL(20,6) NOT NULL,
daily_cost_amount DECIMAL(20,10) NOT NULL,
daily_cost_currency VARCHAR(10) NOT NULL,
report_currency VARCHAR(10) NULL,
crypto_currency VARCHAR(10) NULL,
display_timezone VARCHAR(64) NULL,
fx_max_age_hours DECIMAL(10,2) NULL,
module_theme_mode VARCHAR(16) NULL,
module_theme_accent VARCHAR(16) NULL,
preferred_currencies JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
);
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
starts_at DATETIME NOT NULL,
runtime_months INT NOT NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
base_price_amount DECIMAL(20,8) NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
note TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX idx_miningcheck_cost_plans_project_start
ON miningcheck_cost_plans(project_key, starts_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
measured_at DATETIME NOT NULL,
coins_total DECIMAL(20,6) NOT NULL,
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
price_per_coin DECIMAL(20,8) NULL,
price_currency VARCHAR(10) NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
);
CREATE INDEX idx_miningcheck_measurements_project_measured_at
ON miningcheck_measurements(project_key, measured_at);
CREATE TABLE IF NOT EXISTS miningcheck_targets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
target_amount_fiat DECIMAL(20,2) NOT NULL,
currency VARCHAR(10) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
);
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
measured_at TIMESTAMP NOT NULL,
total_value_amount DECIMAL(20,8) NULL,
total_value_currency VARCHAR(10) NULL,
wallet_balance DECIMAL(28,10) NULL,
wallet_currency VARCHAR(10) NOT NULL,
balances_json JSON NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
name VARCHAR(160) NOT NULL,
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
x_field VARCHAR(64) NOT NULL,
y_field VARCHAR(64) NOT NULL,
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
filters_json JSON NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
);

View File

@@ -0,0 +1,75 @@
INSERT INTO miningcheck_projects (project_key, project_name)
VALUES ('doge-main', 'DOGE Mining Main')
ON DUPLICATE KEY UPDATE project_name = VALUES(project_name);
INSERT INTO miningcheck_settings (
project_key,
baseline_measured_at,
baseline_coins_total,
daily_cost_amount,
daily_cost_currency,
preferred_currencies
)
VALUES (
'doge-main',
'2026-03-16 01:32:00',
27.617864,
0.3123287671,
'EUR',
'["DOGE","USD","EUR"]'
)
ON DUPLICATE KEY UPDATE
baseline_measured_at = VALUES(baseline_measured_at),
baseline_coins_total = VALUES(baseline_coins_total),
daily_cost_amount = VALUES(daily_cost_amount),
daily_cost_currency = VALUES(daily_cost_currency),
preferred_currencies = VALUES(preferred_currencies);
INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order)
VALUES
('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10),
('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20)
ON DUPLICATE KEY UPDATE
target_amount_fiat = VALUES(target_amount_fiat),
currency = VALUES(currency),
is_active = VALUES(is_active),
sort_order = VALUES(sort_order);
INSERT INTO miningcheck_dashboard_definitions (
project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active
)
VALUES
('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1),
('doge-main', 'Performance-Verlauf', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1),
('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currency":"EUR"}', 1)
ON DUPLICATE KEY UPDATE
chart_type = VALUES(chart_type),
x_field = VALUES(x_field),
y_field = VALUES(y_field),
aggregation = VALUES(aggregation),
filters_json = VALUES(filters_json),
is_active = VALUES(is_active);
INSERT INTO miningcheck_measurements (
project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
)
VALUES
('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot'))
ON DUPLICATE KEY UPDATE
price_per_coin = VALUES(price_per_coin),
price_currency = VALUES(price_currency),
note = VALUES(note),
source = VALUES(source);

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -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;

View File

@@ -0,0 +1,223 @@
CREATE TABLE IF NOT EXISTS miningcheck_projects (
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
project_name VARCHAR(160) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS miningcheck_settings (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
baseline_measured_at DATETIME NOT NULL,
baseline_coins_total DECIMAL(20,6) NOT NULL,
daily_cost_amount DECIMAL(20,10) NOT NULL,
daily_cost_currency VARCHAR(10) NOT NULL,
report_currency VARCHAR(10) NULL,
crypto_currency VARCHAR(10) NULL,
display_timezone VARCHAR(64) NULL,
fx_max_age_hours DECIMAL(10,2) NULL,
module_theme_mode VARCHAR(16) NULL,
module_theme_accent VARCHAR(16) NULL,
preferred_currencies JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
);
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
starts_at DATETIME NOT NULL,
runtime_months INT NOT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
base_price_amount DECIMAL(20,8) NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
note TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX idx_miningcheck_cost_plans_project_start
ON miningcheck_cost_plans(project_key, starts_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
measured_at DATETIME NOT NULL,
coins_total DECIMAL(20,6) NOT NULL,
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
price_per_coin DECIMAL(20,8) NULL,
price_currency VARCHAR(10) NULL,
fx_fetch_id BIGINT UNSIGNED NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
);
CREATE INDEX idx_miningcheck_measurements_project_measured_at
ON miningcheck_measurements(project_key, measured_at);
CREATE INDEX idx_miningcheck_measurements_fx_fetch
ON miningcheck_measurements(fx_fetch_id);
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
measurement_id BIGINT UNSIGNED NOT NULL,
project_key VARCHAR(64) NOT NULL,
base_currency VARCHAR(10) NOT NULL,
quote_currency VARCHAR(10) NOT NULL,
rate DECIMAL(20,10) NOT NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency),
KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id)
);
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
payout_at TIMESTAMP NOT NULL,
coins_amount DECIMAL(20,6) NOT NULL,
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
measured_at TIMESTAMP NOT NULL,
total_value_amount DECIMAL(20,8) NULL,
total_value_currency VARCHAR(10) NULL,
wallet_balance DECIMAL(28,10) NULL,
wallet_currency VARCHAR(10) NOT NULL,
balances_json JSON NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
base_price_amount DECIMAL(20,8) NOT NULL,
base_price_currency VARCHAR(10) NOT NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
note TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS miningcheck_targets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
target_amount_fiat DECIMAL(20,2) NOT NULL,
currency VARCHAR(10) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
);
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
name VARCHAR(160) NOT NULL,
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
x_field VARCHAR(64) NOT NULL,
y_field VARCHAR(64) NOT NULL,
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
filters_json JSON NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
);
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
purchased_at TIMESTAMP NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
usd_reference_amount DECIMAL(20,8) NULL,
reference_price_amount DECIMAL(20,8) NULL,
reference_price_currency VARCHAR(10) NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
note TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
base_currency VARCHAR(10) NOT NULL,
rate_date DATE NOT NULL,
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
fetch_id BIGINT UNSIGNED NOT NULL,
currency_code VARCHAR(10) NOT NULL,
current_value DECIMAL(20,10) NOT NULL,
KEY idx_miningcheck_fx_rates_fetch (fetch_id),
KEY idx_miningcheck_fx_rates_currency (currency_code),
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,243 @@
CREATE TABLE IF NOT EXISTS miningcheck_projects (
project_key VARCHAR(64) PRIMARY KEY,
project_name VARCHAR(160) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS miningcheck_settings (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
baseline_measured_at TIMESTAMP NOT NULL,
baseline_coins_total NUMERIC(20,6) NOT NULL,
daily_cost_amount NUMERIC(20,10) NOT NULL,
daily_cost_currency VARCHAR(10) NOT NULL,
report_currency VARCHAR(10),
crypto_currency VARCHAR(10),
display_timezone VARCHAR(64),
fx_max_age_hours NUMERIC(10,2),
module_theme_mode VARCHAR(16),
module_theme_accent VARCHAR(16),
preferred_currencies JSONB,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_settings_project_owner UNIQUE (project_key, owner_sub)
);
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
label VARCHAR(120) NOT NULL,
starts_at TIMESTAMP NOT NULL,
runtime_months INTEGER NOT NULL,
mining_speed_value NUMERIC(20,4),
mining_speed_unit VARCHAR(8),
bonus_speed_value NUMERIC(20,4),
bonus_speed_unit VARCHAR(8),
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
base_price_amount NUMERIC(20,8),
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
total_cost_amount NUMERIC(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
note TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_cost_plans_project_start
ON miningcheck_cost_plans(project_key, owner_sub, starts_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
measured_at TIMESTAMP NOT NULL,
coins_total NUMERIC(20,6) NOT NULL,
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
price_per_coin NUMERIC(20,8),
price_currency VARCHAR(10),
fx_fetch_id BIGINT,
note TEXT,
source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')),
image_path VARCHAR(255),
ocr_raw_text TEXT,
ocr_confidence NUMERIC(6,4),
ocr_flags JSONB,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, owner_sub, measured_at, coins_total)
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_project_measured_at
ON miningcheck_measurements(project_key, owner_sub, measured_at);
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurements_fx_fetch
ON miningcheck_measurements(fx_fetch_id);
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
id BIGSERIAL PRIMARY KEY,
measurement_id BIGINT NOT NULL,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
base_currency VARCHAR(10) NOT NULL,
quote_currency VARCHAR(10) NOT NULL,
rate NUMERIC(20,10) NOT NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency)
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_measurement_rates_project_measurement
ON miningcheck_measurement_rates(project_key, owner_sub, measurement_id);
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
payout_at TIMESTAMP NOT NULL,
coins_amount NUMERIC(20,6) NOT NULL,
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_payouts_project_payout_at
ON miningcheck_payouts(project_key, owner_sub, payout_at);
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
measured_at TIMESTAMP NOT NULL,
total_value_amount NUMERIC(20,8),
total_value_currency VARCHAR(10),
wallet_balance NUMERIC(28,10),
wallet_currency VARCHAR(10) NOT NULL,
balances_json JSONB,
note TEXT,
source VARCHAR(16) NOT NULL CHECK (source IN ('manual', 'image_ocr', 'seed_import')),
image_path VARCHAR(255),
ocr_raw_text TEXT,
ocr_confidence NUMERIC(6,4),
ocr_flags JSONB,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_wallet_snapshots_project_measured_at
ON miningcheck_wallet_snapshots(project_key, owner_sub, measured_at);
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INTEGER,
mining_speed_value NUMERIC(20,4),
mining_speed_unit VARCHAR(8),
bonus_speed_value NUMERIC(20,4),
bonus_speed_unit VARCHAR(8),
base_price_amount NUMERIC(20,8) NOT NULL,
base_price_currency VARCHAR(10) NOT NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
note TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS miningcheck_targets (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
label VARCHAR(120) NOT NULL,
target_amount_fiat NUMERIC(20,2) NOT NULL,
currency VARCHAR(10) NOT NULL,
miner_offer_id BIGINT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, owner_sub, label)
);
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
name VARCHAR(160) NOT NULL,
chart_type VARCHAR(16) NOT NULL CHECK (chart_type IN ('line', 'bar', 'area', 'table')),
x_field VARCHAR(64) NOT NULL,
y_field VARCHAR(64) NOT NULL,
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
filters_json JSONB,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, owner_sub, name)
);
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
id BIGSERIAL PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
miner_offer_id BIGINT,
purchased_at TIMESTAMP NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INTEGER,
mining_speed_value NUMERIC(20,4),
mining_speed_unit VARCHAR(8),
bonus_speed_value NUMERIC(20,4),
bonus_speed_unit VARCHAR(8),
total_cost_amount NUMERIC(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
usd_reference_amount NUMERIC(20,8),
reference_price_amount NUMERIC(20,8),
reference_price_currency VARCHAR(10),
auto_renew BOOLEAN NOT NULL DEFAULT FALSE,
note TEXT,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
id BIGSERIAL PRIMARY KEY,
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
base_currency VARCHAR(10) NOT NULL,
rate_date DATE NOT NULL,
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_fetches_base_fetched
ON miningcheck_fx_fetches(base_currency, fetched_at);
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
id BIGSERIAL PRIMARY KEY,
fetch_id BIGINT NOT NULL,
currency_code VARCHAR(10) NOT NULL,
current_value NUMERIC(20,10) NOT NULL,
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
);
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_fetch
ON miningcheck_fx_rates(fetch_id);
CREATE INDEX IF NOT EXISTS idx_miningcheck_fx_rates_currency
ON miningcheck_fx_rates(currency_code);

View File

@@ -0,0 +1,223 @@
CREATE TABLE IF NOT EXISTS miningcheck_projects (
project_key VARCHAR(64) NOT NULL PRIMARY KEY,
project_name VARCHAR(160) NOT NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP
);
CREATE TABLE IF NOT EXISTS miningcheck_settings (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
baseline_measured_at DATETIME NOT NULL,
baseline_coins_total DECIMAL(20,6) NOT NULL,
daily_cost_amount DECIMAL(20,10) NOT NULL,
daily_cost_currency VARCHAR(10) NOT NULL,
report_currency VARCHAR(10) NULL,
crypto_currency VARCHAR(10) NULL,
display_timezone VARCHAR(64) NULL,
fx_max_age_hours DECIMAL(10,2) NULL,
module_theme_mode VARCHAR(16) NULL,
module_theme_accent VARCHAR(16) NULL,
preferred_currencies JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_settings_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_settings_project UNIQUE (project_key)
);
CREATE TABLE IF NOT EXISTS miningcheck_cost_plans (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
starts_at DATETIME NOT NULL,
runtime_months INT NOT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
base_price_amount DECIMAL(20,8) NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
note TEXT NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_cost_plans_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE INDEX idx_miningcheck_cost_plans_project_start
ON miningcheck_cost_plans(project_key, starts_at);
CREATE TABLE IF NOT EXISTS miningcheck_measurements (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
measured_at DATETIME NOT NULL,
coins_total DECIMAL(20,6) NOT NULL,
coin_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
price_per_coin DECIMAL(20,8) NULL,
price_currency VARCHAR(10) NULL,
fx_fetch_id BIGINT UNSIGNED NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurements_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurements_unique UNIQUE (project_key, measured_at, coins_total)
);
CREATE INDEX idx_miningcheck_measurements_project_measured_at
ON miningcheck_measurements(project_key, measured_at);
CREATE INDEX idx_miningcheck_measurements_fx_fetch
ON miningcheck_measurements(fx_fetch_id);
CREATE TABLE IF NOT EXISTS miningcheck_measurement_rates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
measurement_id BIGINT UNSIGNED NOT NULL,
project_key VARCHAR(64) NOT NULL,
base_currency VARCHAR(10) NOT NULL,
quote_currency VARCHAR(10) NOT NULL,
rate DECIMAL(20,10) NOT NULL,
provider VARCHAR(32) NOT NULL DEFAULT 'derived',
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_measurement_rates_measurement FOREIGN KEY (measurement_id) REFERENCES miningcheck_measurements(id) ON DELETE CASCADE,
CONSTRAINT uq_mining_measurement_rate_pair UNIQUE (measurement_id, base_currency, quote_currency),
KEY idx_miningcheck_measurement_rates_project_measurement (project_key, measurement_id)
);
CREATE TABLE IF NOT EXISTS miningcheck_payouts (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
payout_at TIMESTAMP NOT NULL,
coins_amount DECIMAL(20,6) NOT NULL,
payout_currency VARCHAR(10) NOT NULL DEFAULT 'DOGE',
note TEXT,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_payouts_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
KEY idx_miningcheck_payouts_project_payout_at (project_key, payout_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
owner_sub VARCHAR(128) NOT NULL,
measured_at TIMESTAMP NOT NULL,
total_value_amount DECIMAL(20,8) NULL,
total_value_currency VARCHAR(10) NULL,
wallet_balance DECIMAL(28,10) NULL,
wallet_currency VARCHAR(10) NOT NULL,
balances_json JSON NULL,
note TEXT NULL,
source ENUM('manual', 'image_ocr', 'seed_import') NOT NULL,
image_path VARCHAR(255) NULL,
ocr_raw_text MEDIUMTEXT NULL,
ocr_confidence DECIMAL(6,4) NULL,
ocr_flags JSON NULL,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_wallet_snapshots_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_miner_offers (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
base_price_amount DECIMAL(20,8) NOT NULL,
base_price_currency VARCHAR(10) NOT NULL,
payment_type VARCHAR(10) NOT NULL DEFAULT 'fiat',
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
note TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_miner_offers_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE
);
CREATE TABLE IF NOT EXISTS miningcheck_targets (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
label VARCHAR(120) NOT NULL,
target_amount_fiat DECIMAL(20,2) NOT NULL,
currency VARCHAR(10) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
sort_order INT NOT NULL DEFAULT 0,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_targets_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_targets_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL,
CONSTRAINT uq_mining_targets_project_label UNIQUE (project_key, label)
);
CREATE TABLE IF NOT EXISTS miningcheck_dashboard_definitions (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
name VARCHAR(160) NOT NULL,
chart_type ENUM('line', 'bar', 'area', 'table') NOT NULL,
x_field VARCHAR(64) NOT NULL,
y_field VARCHAR(64) NOT NULL,
aggregation VARCHAR(32) NOT NULL DEFAULT 'none',
filters_json JSON NULL,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_dashboards_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT uq_mining_dashboards_project_name UNIQUE (project_key, name)
);
CREATE TABLE IF NOT EXISTS miningcheck_purchased_miners (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
project_key VARCHAR(64) NOT NULL,
miner_offer_id BIGINT UNSIGNED NULL,
purchased_at TIMESTAMP NOT NULL,
label VARCHAR(120) NOT NULL,
runtime_months INT NULL,
mining_speed_value DECIMAL(20,4) NULL,
mining_speed_unit VARCHAR(8) NULL,
bonus_speed_value DECIMAL(20,4) NULL,
bonus_speed_unit VARCHAR(8) NULL,
total_cost_amount DECIMAL(20,8) NOT NULL,
currency VARCHAR(10) NOT NULL,
usd_reference_amount DECIMAL(20,8) NULL,
reference_price_amount DECIMAL(20,8) NULL,
reference_price_currency VARCHAR(10) NULL,
auto_renew TINYINT(1) NOT NULL DEFAULT 0,
note TEXT,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP ON UPDATE CURRENT_TIMESTAMP,
CONSTRAINT fk_mining_purchased_miners_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE,
CONSTRAINT fk_mining_purchased_miners_offer FOREIGN KEY (miner_offer_id) REFERENCES miningcheck_miner_offers(id) ON DELETE SET NULL
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_fetches (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
provider VARCHAR(32) NOT NULL DEFAULT 'currencyapi',
base_currency VARCHAR(10) NOT NULL,
rate_date DATE NOT NULL,
fetched_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
KEY idx_miningcheck_fx_fetches_base_fetched (base_currency, fetched_at)
);
CREATE TABLE IF NOT EXISTS miningcheck_fx_rates (
id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY,
fetch_id BIGINT UNSIGNED NOT NULL,
currency_code VARCHAR(10) NOT NULL,
current_value DECIMAL(20,10) NOT NULL,
KEY idx_miningcheck_fx_rates_fetch (fetch_id),
KEY idx_miningcheck_fx_rates_currency (currency_code),
CONSTRAINT fk_mining_fx_rates_fetch FOREIGN KEY (fetch_id) REFERENCES miningcheck_fx_fetches(id) ON DELETE CASCADE
);

View File

@@ -0,0 +1,75 @@
INSERT INTO miningcheck_projects (project_key, project_name)
VALUES ('doge-main', 'DOGE Mining Main')
ON DUPLICATE KEY UPDATE project_name = VALUES(project_name);
INSERT INTO miningcheck_settings (
project_key,
baseline_measured_at,
baseline_coins_total,
daily_cost_amount,
daily_cost_currency,
preferred_currencies
)
VALUES (
'doge-main',
'2026-03-16 01:32:00',
27.617864,
0.3123287671,
'EUR',
'["DOGE","USD","EUR"]'
)
ON DUPLICATE KEY UPDATE
baseline_measured_at = VALUES(baseline_measured_at),
baseline_coins_total = VALUES(baseline_coins_total),
daily_cost_amount = VALUES(daily_cost_amount),
daily_cost_currency = VALUES(daily_cost_currency),
preferred_currencies = VALUES(preferred_currencies);
INSERT INTO miningcheck_targets (project_key, label, target_amount_fiat, currency, is_active, sort_order)
VALUES
('doge-main', 'Ziel A', 10.82, 'EUR', 1, 10),
('doge-main', 'Ziel B', 19.50, 'EUR', 1, 20)
ON DUPLICATE KEY UPDATE
target_amount_fiat = VALUES(target_amount_fiat),
currency = VALUES(currency),
is_active = VALUES(is_active),
sort_order = VALUES(sort_order);
INSERT INTO miningcheck_dashboard_definitions (
project_key, name, chart_type, x_field, y_field, aggregation, filters_json, is_active
)
VALUES
('doge-main', 'Mining-Verlauf', 'line', 'measured_at', 'coins_total', 'none', NULL, 1),
('doge-main', 'DOGE pro Tag', 'area', 'measured_date', 'doge_per_day_interval', 'avg', NULL, 1),
('doge-main', 'Kurs-Verlauf', 'line', 'measured_at', 'price_per_coin', 'none', '{"currencies":["EUR","USD"]}', 1)
ON DUPLICATE KEY UPDATE
chart_type = VALUES(chart_type),
x_field = VALUES(x_field),
y_field = VALUES(y_field),
aggregation = VALUES(aggregation),
filters_json = VALUES(filters_json),
is_active = VALUES(is_active);
INSERT INTO miningcheck_measurements (
project_key, measured_at, coins_total, price_per_coin, price_currency, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
)
VALUES
('doge-main', '2026-03-16 01:32:00', 27.617864, NULL, NULL, 'Basiswert', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 02:41:00', 33.751904, NULL, NULL, 'Kurs wurde spaeter separat genannt, aber nicht sicher exakt diesem Messpunkt zuordenbar', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 07:15:00', 34.825695, 0.10037, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 13:21:00', 36.328140, 0.10002, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-17 18:53:00', 37.682757, 0.10062, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 00:08:00', 38.934351, 0.10097, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 07:40:00', 40.782006, 0.10040, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 13:32:00', 42.223449, 0.09607, 'EUR', 'Originaleingabe im Chat: 18.6.2026', 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-18 21:15:00', 44.191018, 0.09446, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-19 00:09:00', 44.908500, 0.09507, 'EUR', NULL, 'seed_import', NULL, NULL, NULL, NULL),
('doge-main', '2026-03-19 02:33:00', 45.546924, 0.09499, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 07:01:00', 46.694127, 0.09460, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 12:24:00', 48.056494, 0.09419, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot')),
('doge-main', '2026-03-19 21:39:00', 50.427943, 0.09361, 'USD', 'aus Screenshot extrahiert', 'seed_import', NULL, NULL, NULL, JSON_ARRAY('source:screenshot'))
ON DUPLICATE KEY UPDATE
price_per_coin = VALUES(price_per_coin),
price_currency = VALUES(price_currency),
note = VALUES(note),
source = VALUES(source);

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,950 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
use Modules\MiningChecker\Infrastructure\MiningRepository;
use Modules\MiningChecker\Support\DebugTrace;
final class FxService
{
private ?MiningRepository $repository;
private string $provider;
private string $apiBaseUrl;
private string $currenciesApiBaseUrl;
private string $apiKey;
private int $timeout;
private int $cacheTtl;
private bool $autoFetchOnMiss;
private array $memoryCache = [];
private array $snapshotCache = [];
private ?DebugTrace $debug;
public function __construct(
?MiningRepository $repository = null,
string $apiBaseUrl = 'https://currencyapi.net',
string $currenciesApiBaseUrl = 'https://currencyapi.net',
int $timeout = 10,
int $cacheTtl = 21600,
bool $autoFetchOnMiss = false,
string $provider = 'currencyapi',
string $apiKey = '',
?DebugTrace $debug = null
)
{
$this->repository = $repository;
$this->provider = trim(strtolower($provider)) !== '' ? trim(strtolower($provider)) : 'currencyapi';
$this->apiBaseUrl = rtrim($apiBaseUrl, '/');
$this->currenciesApiBaseUrl = rtrim($currenciesApiBaseUrl, '/');
$this->apiKey = trim($apiKey);
$this->timeout = max(2, $timeout);
$this->cacheTtl = max(60, $cacheTtl);
$this->autoFetchOnMiss = $autoFetchOnMiss;
$this->debug = $debug;
}
public function convert(?float $amount, ?string $from, ?string $to): ?float
{
return $this->convertAt($amount, $from, $to, null, null, null);
}
public function convertAt(?float $amount, ?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float
{
if ($amount === null || $from === null || $to === null) {
return null;
}
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
$shared = $this->sharedFxService();
if ($shared !== null && $normalizedFetchId !== null) {
$snapshot = $this->snapshotByFetchId($normalizedFetchId, strtoupper(trim((string) $from)), [strtoupper(trim((string) $to))]);
if (is_array($snapshot)) {
$resolved = $this->resolveRateFromSnapshot($snapshot, strtoupper(trim((string) $from)), strtoupper(trim((string) $to)));
if ($resolved !== null) {
return $amount * $resolved;
}
}
}
if ($shared !== null && method_exists($shared, 'convert')) {
$converted = $shared->convert($amount, $from, $to, $at, $windowMinutes);
return is_numeric($converted) ? (float) $converted : null;
}
$rate = $this->rateAt($from, $to, $at, $windowMinutes, $normalizedFetchId);
return $rate === null ? null : $amount * $rate;
}
public function rate(?string $from, ?string $to): ?float
{
return $this->rateAt($from, $to, null, null, null);
}
public function rateAt(?string $from, ?string $to, ?string $at = null, ?int $windowMinutes = null, ?int $fetchId = null): ?float
{
$base = strtoupper(trim((string) $from));
$target = strtoupper(trim((string) $to));
$normalizedFetchId = $fetchId !== null && $fetchId > 0 ? $fetchId : null;
if ($base === '' || $target === '') {
return null;
}
if ($base === $target) {
return 1.0;
}
$shared = $this->sharedFxService();
if ($shared !== null && $normalizedFetchId !== null) {
$snapshot = $this->snapshotByFetchId($normalizedFetchId, $base, [$target]);
if (is_array($snapshot)) {
$resolved = $this->resolveRateFromSnapshot($snapshot, $base, $target);
if ($resolved !== null) {
return $resolved;
}
}
}
if ($shared !== null && method_exists($shared, 'findRate')) {
$resolved = $shared->findRate($from, $to, $at, $windowMinutes);
return is_array($resolved) && is_numeric($resolved['rate'] ?? null) ? (float) $resolved['rate'] : null;
}
$cacheKey = implode(':', [$base, $target, $at ?? '', (string) ($windowMinutes ?? 0), (string) ($normalizedFetchId ?? 0)]);
if (array_key_exists($cacheKey, $this->memoryCache)) {
return $this->memoryCache[$cacheKey];
}
$stored = $this->storedRate($base, $target);
if ($stored !== null) {
$this->memoryCache[$cacheKey] = $stored;
return $stored;
}
$cached = $this->readFileCache($cacheKey);
if ($cached !== null) {
$this->memoryCache[$cacheKey] = $cached;
return $cached;
}
if (!$this->autoFetchOnMiss) {
return null;
}
$rate = $this->fetchAndPersistRate($base, $target);
$this->memoryCache[$cacheKey] = $rate;
if ($rate !== null) {
$this->writeFileCache($cacheKey, $rate);
}
return $rate;
}
public function snapshotByFetchId(int $fetchId, ?string $baseCurrency = null, ?array $symbols = null): ?array
{
if ($fetchId <= 0) {
return null;
}
$cacheKey = $this->snapshotCacheKey('fetch', [
$fetchId,
strtoupper(trim((string) ($baseCurrency ?? ''))),
$this->normalizeSymbolsForCache($symbols),
]);
if (array_key_exists($cacheKey, $this->snapshotCache)) {
return $this->snapshotCache[$cacheKey];
}
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'snapshotByFetchId')) {
$snapshot = $shared->snapshotByFetchId($fetchId, $baseCurrency, $symbols);
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
}
return $this->snapshotCache[$cacheKey] = null;
}
public function latestSnapshot(?string $baseCurrency = null, ?array $symbols = null): ?array
{
$cacheKey = $this->snapshotCacheKey('latest', [
strtoupper(trim((string) ($baseCurrency ?? ''))),
$this->normalizeSymbolsForCache($symbols),
]);
if (array_key_exists($cacheKey, $this->snapshotCache)) {
return $this->snapshotCache[$cacheKey];
}
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'snapshot')) {
$snapshot = $shared->snapshot($baseCurrency, null, $symbols, null);
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
}
return $this->snapshotCache[$cacheKey] = null;
}
public function nearestSnapshot(?string $baseCurrency, string $at, ?array $symbols = null, ?int $windowMinutes = null): ?array
{
$cacheKey = $this->snapshotCacheKey('nearest', [
strtoupper(trim((string) ($baseCurrency ?? ''))),
trim($at),
$windowMinutes ?? 0,
$this->normalizeSymbolsForCache($symbols),
]);
if (array_key_exists($cacheKey, $this->snapshotCache)) {
return $this->snapshotCache[$cacheKey];
}
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'nearestSnapshot')) {
$snapshot = $shared->nearestSnapshot($baseCurrency, $at, $symbols, $windowMinutes);
return $this->snapshotCache[$cacheKey] = (is_array($snapshot) ? $snapshot : null);
}
return $this->snapshotCache[$cacheKey] = null;
}
public function refreshLatestRates(?array $currencies = null, string $base = 'EUR'): array
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'refreshLatestRates')) {
return $shared->refreshLatestRates($currencies, $base);
}
$normalizedBase = strtoupper(trim($base));
$targets = $currencies === null
? null
: array_values(array_unique(array_filter(array_map(
static fn ($code): string => strtoupper(trim((string) $code)),
$currencies
), static fn (string $code): bool => $code !== '' && $code !== $normalizedBase)));
$payload = $this->fetchLatestPayload($normalizedBase, $targets);
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
$forwardRates = [];
foreach ($rates as $target => $rate) {
if (!is_numeric($rate)) {
continue;
}
$targetCode = strtoupper((string) $target);
if ($targetCode === '' || $targetCode === $normalizedBase) {
continue;
}
$forwardRates[$targetCode] = (float) $rate;
}
$updated = $this->persistRateSet($normalizedBase, $forwardRates, $rateDate);
return [
'base' => $normalizedBase,
'rate_date' => $rateDate,
'updated_count' => count($updated),
'rates' => $updated,
];
}
public function ensureFreshLatestRates(float $maxAgeHours = 3.0, string $base = 'USD'): array
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'ensureFreshLatestRates')) {
return $shared->ensureFreshLatestRates($maxAgeHours, $base, null);
}
$normalizedBase = strtoupper(trim($base));
$maxAgeHours = $maxAgeHours > 0 ? $maxAgeHours : 3.0;
if ($this->repository === null) {
return $this->refreshLatestRates(null, $normalizedBase);
}
$latestFetch = $this->repository->getLatestFxFetch($normalizedBase);
$latestFetchedAt = is_array($latestFetch) ? $this->parseStoredUtcTimestamp((string) ($latestFetch['fetched_at'] ?? '')) : null;
$ageSeconds = $latestFetchedAt !== null ? (time() - $latestFetchedAt) : null;
$maxAgeSeconds = (int) round($maxAgeHours * 3600);
if ($ageSeconds !== null && $ageSeconds >= 0 && $ageSeconds <= $maxAgeSeconds) {
$this->debug?->add('fx.latest.reuse', [
'base' => $normalizedBase,
'fetched_at' => $latestFetch['fetched_at'] ?? null,
'age_seconds' => $ageSeconds,
'max_age_seconds' => $maxAgeSeconds,
]);
return [
'base' => $normalizedBase,
'rate_date' => $latestFetch['rate_date'] ?? null,
'updated_count' => 0,
'rates' => [],
'reused' => true,
'fetched_at' => $latestFetch['fetched_at'] ?? null,
];
}
$this->debug?->add('fx.latest.refresh_required', [
'base' => $normalizedBase,
'previous_fetched_at' => $latestFetch['fetched_at'] ?? null,
'age_seconds' => $ageSeconds,
'max_age_seconds' => $maxAgeSeconds,
]);
$result = $this->refreshLatestRates(null, $normalizedBase);
$result['reused'] = false;
return $result;
}
public function probeLatestRates(string $base = 'EUR'): array
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'probeLatestRates')) {
return $shared->probeLatestRates($base);
}
$normalizedBase = strtoupper(trim($base));
return $this->fetchLatestProbe($normalizedBase);
}
public function refreshCurrencyCatalog(): array
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'refreshCurrencyCatalog')) {
return $shared->refreshCurrencyCatalog();
}
$payload = $this->fetchCurrenciesPayload();
$items = is_array($payload['currencies'] ?? null) ? $payload['currencies'] : [];
if ($items === []) {
return [
'synced_count' => 0,
'currencies' => [],
];
}
$synced = [];
$sortOrder = 1000;
foreach ($items as $code => $name) {
$normalizedCode = strtoupper(trim((string) $code));
$normalizedName = trim((string) $name);
if ($normalizedCode === '' || $normalizedName === '') {
continue;
}
$currency = [
'code' => substr($normalizedCode, 0, 10),
'name' => function_exists('mb_substr') ? mb_substr($normalizedName, 0, 64) : substr($normalizedName, 0, 64),
'symbol' => substr($normalizedCode, 0, 8),
'is_active' => 1,
'is_crypto' => $this->isCryptoCode($normalizedCode) ? 1 : 0,
'sort_order' => $this->catalogSortOrder($normalizedCode, $sortOrder),
];
$synced[] = $currency;
$sortOrder++;
}
usort($synced, static function (array $left, array $right): int {
return [$left['sort_order'], $left['code']] <=> [$right['sort_order'], $right['code']];
});
return [
'synced_count' => count($synced),
'currencies' => $synced,
];
}
public function probeCurrencyCatalog(): array
{
$shared = $this->sharedFxService();
if ($shared !== null && method_exists($shared, 'probeCurrencyCatalog')) {
return $shared->probeCurrencyCatalog();
}
return $this->fetchCurrenciesProbe();
}
private function fetchAndPersistRate(string $base, string $target): ?float
{
$payload = $this->fetchLatestPayload($base, [$target]);
$rates = is_array($payload['rates'] ?? null) ? $payload['rates'] : [];
$rate = $rates[$target] ?? null;
if (!is_numeric($rate)) {
return null;
}
$numericRate = (float) $rate;
$rateDate = $this->normalizeRateDate($payload['date'] ?? null);
$this->persistRateSet($base, [$target => $numericRate], $rateDate);
return $numericRate;
}
private function fetchLatestPayload(string $base, ?array $targets = null): array
{
if (!function_exists('curl_init')) {
return [];
}
$url = $this->buildLatestUrl($base, $targets);
if ($url === null) {
$this->debug?->add('fx.latest.skip', ['reason' => 'missing_url_or_key', 'base' => $base]);
return [];
}
$this->debug?->add('fx.latest.request', [
'base' => $base,
'url' => $this->maskUrl($url),
'targets' => $targets,
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$curlError = curl_error($ch);
curl_close($ch);
$this->debug?->add('fx.latest.response', [
'http_status' => $httpStatus,
'curl_error' => $curlError,
'response_bytes' => is_string($response) ? strlen($response) : 0,
'response_preview' => is_string($response) ? substr($response, 0, 1200) : null,
]);
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
return [];
}
$payload = json_decode((string) $response, true);
return $this->normalizePayload($payload, $base, $targets);
}
private function fetchLatestProbe(string $base): array
{
if (!function_exists('curl_init')) {
return ['ok' => false, 'message' => 'curl_init ist nicht verfuegbar.'];
}
$url = $this->buildLatestUrl($base, null);
if ($url === null) {
return ['ok' => false, 'message' => 'FX-URL oder API-Key fehlt.'];
}
$this->debug?->add('fx.latest.probe.request', [
'base' => $base,
'url' => $this->maskUrl($url),
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$curlError = curl_error($ch);
curl_close($ch);
$rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : '';
$body = is_string($response) ? substr($response, $headerSize) : '';
$result = [
'ok' => $response !== false && $curlError === '' && $httpStatus < 400,
'url' => $this->maskUrl($url),
'http_status' => $httpStatus,
'curl_error' => $curlError,
'response_headers' => $rawHeaders,
'response_body' => substr($body, 0, 4000),
];
$this->debug?->add('fx.latest.probe.response', $result);
return $result;
}
private function fetchCurrenciesPayload(): array
{
if (!function_exists('curl_init') || $this->apiKey === '') {
return [];
}
$url = sprintf(
'%s/api/v2/currencies?output=json&key=%s',
$this->currenciesApiBaseUrl,
rawurlencode($this->apiKey)
);
$this->debug?->add('fx.currencies.request', [
'url' => $this->maskUrl($url),
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$curlError = curl_error($ch);
curl_close($ch);
$this->debug?->add('fx.currencies.response', [
'http_status' => $httpStatus,
'curl_error' => $curlError,
'response_bytes' => is_string($response) ? strlen($response) : 0,
'response_preview' => is_string($response) ? substr($response, 0, 1200) : null,
]);
if ($response === false || $curlError !== '' || $httpStatus >= 400) {
return [];
}
$payload = json_decode((string) $response, true);
if (!is_array($payload)) {
throw new \RuntimeException('Waehrungskatalog konnte nicht gelesen werden.');
}
if (($payload['valid'] ?? false) !== true || !is_array($payload['currencies'] ?? null)) {
throw new \RuntimeException($this->extractProviderError($payload, 'Waehrungskatalog konnte nicht geladen werden.'));
}
return $payload;
}
private function fetchCurrenciesProbe(): array
{
if (!function_exists('curl_init') || $this->apiKey === '') {
return ['ok' => false, 'message' => 'curl_init oder API-Key fehlt.'];
}
$url = sprintf(
'%s/api/v2/currencies?output=json&key=%s',
$this->currenciesApiBaseUrl,
rawurlencode($this->apiKey)
);
$this->debug?->add('fx.currencies.probe.request', [
'url' => $this->maskUrl($url),
]);
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => $this->timeout,
CURLOPT_HEADER => true,
CURLOPT_HTTPHEADER => ['Accept: application/json'],
]);
$response = curl_exec($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
$headerSize = (int) curl_getinfo($ch, CURLINFO_HEADER_SIZE);
$curlError = curl_error($ch);
curl_close($ch);
$rawHeaders = is_string($response) ? substr($response, 0, $headerSize) : '';
$body = is_string($response) ? substr($response, $headerSize) : '';
$result = [
'ok' => $response !== false && $curlError === '' && $httpStatus < 400,
'url' => $this->maskUrl($url),
'http_status' => $httpStatus,
'curl_error' => $curlError,
'response_headers' => $rawHeaders,
'response_body' => substr($body, 0, 4000),
];
$this->debug?->add('fx.currencies.probe.response', $result);
return $result;
}
private function storedRate(string $base, string $target): ?float
{
if ($this->repository === null) {
return null;
}
try {
$direct = $this->repository->getLatestFxRate($base, $target);
if (is_array($direct) && is_numeric($direct['rate'] ?? null)) {
return (float) $direct['rate'];
}
$inverse = $this->repository->getLatestFxRate($target, $base);
if (is_array($inverse) && is_numeric($inverse['rate'] ?? null) && (float) $inverse['rate'] > 0) {
return 1 / (float) $inverse['rate'];
}
$measurementRate = $this->repository->getLatestMeasurementRate($base, $target);
if (is_array($measurementRate) && is_numeric($measurementRate['rate'] ?? null)) {
return (float) $measurementRate['rate'];
}
$inverseMeasurementRate = $this->repository->getLatestMeasurementRate($target, $base);
if (
is_array($inverseMeasurementRate) &&
is_numeric($inverseMeasurementRate['rate'] ?? null) &&
(float) $inverseMeasurementRate['rate'] > 0
) {
return 1 / (float) $inverseMeasurementRate['rate'];
}
foreach (['USD', 'EUR'] as $viaBase) {
if ($base === $viaBase || $target === $viaBase) {
continue;
}
$fromVia = $this->repository->getLatestFxRate($viaBase, $base);
$toVia = $this->repository->getLatestFxRate($viaBase, $target);
if (
is_array($fromVia) && is_numeric($fromVia['rate'] ?? null) &&
is_array($toVia) && is_numeric($toVia['rate'] ?? null) &&
(float) $fromVia['rate'] > 0
) {
return (float) $toVia['rate'] / (float) $fromVia['rate'];
}
$fromViaInverse = $this->repository->getLatestFxRate($base, $viaBase);
$toViaInverse = $this->repository->getLatestFxRate($target, $viaBase);
if (
is_array($fromViaInverse) && is_numeric($fromViaInverse['rate'] ?? null) &&
is_array($toViaInverse) && is_numeric($toViaInverse['rate'] ?? null) &&
(float) $toViaInverse['rate'] > 0
) {
return (1 / (float) $fromViaInverse['rate']) / (1 / (float) $toViaInverse['rate']);
}
}
} catch (\Throwable) {
return null;
}
return null;
}
private function persistRateSet(string $base, array $rates, string $rateDate): array
{
$normalizedBase = strtoupper($base);
$normalizedRates = [];
foreach ($rates as $target => $rate) {
if (!is_numeric($rate)) {
continue;
}
$normalizedTarget = strtoupper((string) $target);
$normalizedRates[$normalizedTarget] = (float) $rate;
$this->memoryCache[$normalizedBase . ':' . $normalizedTarget] = (float) $rate;
$this->writeFileCache($normalizedBase . ':' . $normalizedTarget, (float) $rate);
}
if ($this->repository === null) {
$result = [];
foreach ($normalizedRates as $target => $rate) {
$result[] = [
'base_currency' => $normalizedBase,
'target_currency' => $target,
'rate' => $rate,
'rate_date' => $rateDate,
'provider' => $this->provider,
];
}
return $result;
}
try {
$saved = $this->repository->saveFxFetch($normalizedBase, $this->provider, $rateDate, $normalizedRates);
return is_array($saved['rates'] ?? null) ? $saved['rates'] : [];
} catch (\Throwable) {
$result = [];
foreach ($normalizedRates as $target => $rate) {
$result[] = [
'base_currency' => $normalizedBase,
'target_currency' => $target,
'rate' => $rate,
'rate_date' => $rateDate,
'provider' => $this->provider,
];
}
return $result;
}
}
private function buildLatestUrl(string $base, ?array $targets = null): ?string
{
if ($this->provider === 'currencyapi') {
if ($this->apiKey === '') {
return null;
}
return sprintf(
'%s/api/v2/rates?base=%s&output=json&key=%s',
$this->apiBaseUrl,
rawurlencode($base),
rawurlencode($this->apiKey)
);
}
$targets = $targets ?? $this->defaultCurrencies();
return sprintf(
'%s/latest?base=%s&symbols=%s',
$this->apiBaseUrl,
rawurlencode($base),
rawurlencode(implode(',', $targets))
);
}
private function normalizePayload(mixed $payload, string $base, ?array $targets = null): array
{
if (!is_array($payload)) {
return [];
}
if ($this->provider === 'currencyapi') {
if (($payload['valid'] ?? false) !== true || !is_array($payload['rates'] ?? null)) {
throw new \RuntimeException($this->extractProviderError($payload, 'FX-Kurse konnten nicht geladen werden.'));
}
$allRates = $payload['rates'];
$filteredRates = [];
if ($targets === null) {
foreach ($allRates as $target => $rate) {
$targetCode = strtoupper((string) $target);
if ($targetCode === $base || !is_numeric($rate)) {
continue;
}
$filteredRates[$targetCode] = (float) $rate;
}
} else {
foreach ($targets as $target) {
$targetCode = strtoupper((string) $target);
if ($targetCode === $base) {
continue;
}
$rate = $allRates[$targetCode] ?? null;
if (is_numeric($rate)) {
$filteredRates[$targetCode] = (float) $rate;
}
}
}
return [
'base' => strtoupper((string) ($payload['base'] ?? $base)),
'date' => $payload['updated'] ?? null,
'rates' => $filteredRates,
];
}
if (!is_array($payload['rates'] ?? null)) {
return [];
}
if (array_key_exists('success', $payload) && $payload['success'] !== true) {
return [];
}
return $payload;
}
private function extractProviderError(array $payload, string $fallback): string
{
foreach (['error', 'message', 'msg'] as $field) {
$value = $payload[$field] ?? null;
if (is_string($value) && trim($value) !== '') {
return trim($value);
}
}
$errors = $payload['errors'] ?? null;
if (is_array($errors)) {
$flat = [];
array_walk_recursive($errors, static function ($value) use (&$flat): void {
if (is_string($value) && trim($value) !== '') {
$flat[] = trim($value);
}
});
if ($flat !== []) {
return implode(' | ', array_values(array_unique($flat)));
}
}
return $fallback;
}
private function defaultCurrencies(): array
{
return ['EUR', 'USD'];
}
private function normalizeRateDate(mixed $value): string
{
if (is_int($value) || is_float($value) || (is_string($value) && ctype_digit(trim($value)))) {
$timestamp = (int) $value;
if ($timestamp > 0) {
return date('Y-m-d', $timestamp);
}
}
if (is_string($value) && trim($value) !== '') {
$timestamp = strtotime($value);
if ($timestamp !== false) {
return date('Y-m-d', $timestamp);
}
if (preg_match('/^\d{4}-\d{2}-\d{2}/', $value, $matches) === 1) {
return $matches[0];
}
}
return date('Y-m-d');
}
private function parseStoredUtcTimestamp(string $value): ?int
{
$normalized = trim($value);
if ($normalized === '') {
return null;
}
try {
if (preg_match('/^\d{4}-\d{2}-\d{2}(?:[ T]\d{2}:\d{2}(?::\d{2})?)?$/', $normalized) === 1) {
$date = new \DateTimeImmutable(str_replace(' ', 'T', $normalized), new \DateTimeZone('UTC'));
} else {
$date = new \DateTimeImmutable($normalized);
}
return $date->setTimezone(new \DateTimeZone('UTC'))->getTimestamp();
} catch (\Throwable) {
return null;
}
}
private function catalogSortOrder(string $code, int $fallback): int
{
return match (strtoupper($code)) {
'EUR' => 10,
'USD' => 20,
'DOGE' => 30,
'BTC' => 40,
'ETH' => 50,
'USDT' => 60,
'USDC' => 70,
default => $fallback,
};
}
private function isCryptoCode(string $code): bool
{
return in_array(strtoupper($code), [
'ADA', 'ARB', 'BNB', 'BTC', 'DAI', 'DOGE', 'DOT', 'ETH', 'LINK', 'LTC',
'SOL', 'USDC', 'USDT', 'XRP',
], true);
}
private function cacheFile(string $cacheKey): string
{
return rtrim(sys_get_temp_dir(), '/') . '/mining-checker-fx-' . md5($cacheKey) . '.json';
}
private function readFileCache(string $cacheKey): ?float
{
$file = $this->cacheFile($cacheKey);
if (!is_file($file) || (time() - filemtime($file)) > $this->cacheTtl) {
return null;
}
$payload = json_decode((string) file_get_contents($file), true);
$rate = $payload['rate'] ?? null;
return is_numeric($rate) ? (float) $rate : null;
}
private function writeFileCache(string $cacheKey, float $rate): void
{
@file_put_contents($this->cacheFile($cacheKey), json_encode([
'rate' => $rate,
'cached_at' => time(),
], JSON_UNESCAPED_UNICODE));
}
private function maskUrl(string $url): string
{
return preg_replace_callback('/([?&]key=)([^&]+)/i', static function (array $matches): string {
$key = $matches[2] ?? '';
if (strlen($key) <= 8) {
return $matches[1] . $key;
}
return $matches[1] . substr($key, 0, 6) . '...' . substr($key, -4);
}, $url) ?: $url;
}
private function sharedFxService(): ?object
{
if (!function_exists('modules') || !modules()->isEnabled('fx-rates') || !modules()->hasFunction('fx-rates', 'service')) {
return null;
}
try {
$service = module_fn('fx-rates', 'service');
return is_object($service) ? $service : null;
} catch (\Throwable) {
return null;
}
}
private function resolveRateFromSnapshot(array $snapshot, string $from, string $to): ?float
{
$base = strtoupper(trim((string) ($snapshot['base_currency'] ?? '')));
$rates = is_array($snapshot['rates'] ?? null) ? $snapshot['rates'] : [];
if ($base === '' || $from === '' || $to === '') {
return null;
}
if ($base === $from && is_numeric($rates[$to] ?? null)) {
return (float) $rates[$to];
}
if ($base === $to && is_numeric($rates[$from] ?? null) && (float) $rates[$from] > 0) {
return 1 / (float) $rates[$from];
}
if (is_numeric($rates[$from] ?? null) && is_numeric($rates[$to] ?? null) && (float) $rates[$from] > 0) {
return (float) $rates[$to] / (float) $rates[$from];
}
return null;
}
private function snapshotCacheKey(string $prefix, array $parts): string
{
return $prefix . ':' . implode(':', array_map(static fn (mixed $part): string => (string) $part, $parts));
}
private function normalizeSymbolsForCache(?array $symbols): string
{
if (!is_array($symbols) || $symbols === []) {
return '*';
}
$normalized = array_values(array_unique(array_filter(array_map(
static fn (mixed $symbol): string => strtoupper(trim((string) $symbol)),
$symbols
))));
sort($normalized);
return implode(',', $normalized);
}
}

View File

@@ -0,0 +1,665 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
use Modules\MiningChecker\Infrastructure\ModuleConfig;
use Modules\MiningChecker\Support\ApiException;
final class OcrService
{
private ModuleConfig $config;
public function __construct(ModuleConfig $config)
{
$this->config = $config;
}
public function preview(array $file, array $input): array
{
if (($file['error'] ?? UPLOAD_ERR_NO_FILE) !== UPLOAD_ERR_OK) {
throw new ApiException('Screenshot-Upload fehlt oder ist fehlerhaft.', 422);
}
$mime = mime_content_type($file['tmp_name']) ?: '';
if (!in_array($mime, ['image/png', 'image/jpeg', 'image/webp'], true)) {
throw new ApiException('Nur PNG, JPEG und WEBP werden akzeptiert.', 422, ['mime' => $mime]);
}
$projectKey = (string) ($input['project_key'] ?? $this->config->defaultProjectKey());
$uploadDir = $this->resolveUploadDir($projectKey);
$extension = pathinfo((string) ($file['name'] ?? 'upload.png'), PATHINFO_EXTENSION) ?: 'png';
$filename = date('Ymd-His') . '-' . bin2hex(random_bytes(4)) . '.' . strtolower($extension);
$targetFile = $uploadDir . '/' . $filename;
if (!move_uploaded_file($file['tmp_name'], $targetFile)) {
throw new ApiException('Bild konnte nicht gespeichert werden.', 500);
}
$rawText = trim((string) ($input['ocr_hint_text'] ?? ''));
$flags = [];
if ($rawText === '') {
['text' => $rawText, 'flags' => $providerFlags] = $this->extractRawText($targetFile);
$flags = array_merge($flags, $providerFlags);
} else {
$flags[] = 'ocr_hint_text_used';
}
$parsed = $this->parseText(
$rawText,
(string) ($input['date_context'] ?? date('Y-m-d')),
strtoupper(trim((string) ($input['wallet_currency_hint'] ?? '')))
);
$parsed['image_path'] = $targetFile;
$parsed['raw_text'] = $rawText;
$parsed['flags'] = array_values(array_unique(array_merge($flags, $parsed['flags'])));
return $parsed;
}
private function resolveUploadDir(string $projectKey): string
{
$safeProjectKey = preg_replace('~[^a-zA-Z0-9_-]~', '-', $projectKey) ?: 'default';
$candidates = [
rtrim($this->config->uploadsDir(), '/') . '/' . $safeProjectKey,
rtrim(sys_get_temp_dir(), '/') . '/mining-checker/uploads/' . $safeProjectKey,
];
foreach ($candidates as $candidate) {
if ($this->ensureWritableDirectory($candidate)) {
return $candidate;
}
}
throw new ApiException('Upload-Verzeichnis konnte nicht erstellt werden.', 500, [
'candidates' => $candidates,
]);
}
private function ensureWritableDirectory(string $directory): bool
{
if (is_dir($directory)) {
return is_writable($directory);
}
return @mkdir($directory, 0775, true) || is_dir($directory);
}
private function extractRawText(string $imagePath): array
{
$ocrConfig = $this->config->ocr();
$providers = $ocrConfig['providers'] ?? ['tesseract'];
$flags = [];
if (!is_array($providers) || $providers === []) {
$providers = ['tesseract'];
}
foreach ($providers as $provider) {
$providerName = strtolower(trim((string) $provider));
if ($providerName === '') {
continue;
}
if ($providerName === 'ocrspace') {
$result = $this->runOcrSpace((array) ($ocrConfig['ocrspace'] ?? []), $imagePath);
} elseif ($providerName === 'tesseract') {
$result = $this->runTesseract((array) ($ocrConfig['tesseract'] ?? []), $imagePath);
} else {
$flags[] = 'ocr_provider_unsupported:' . $providerName;
continue;
}
$flags = array_merge($flags, $result['flags']);
if (($result['text'] ?? '') !== '') {
return [
'text' => (string) $result['text'],
'flags' => array_values(array_unique(array_merge(
$flags,
['ocr_provider:' . $providerName]
))),
];
}
}
return [
'text' => '',
'flags' => array_values(array_unique(array_merge($flags, ['ocr_engine_missing']))),
];
}
private function runOcrSpace(array $providerConfig, string $imagePath): array
{
if (!function_exists('curl_init') || !class_exists(\CURLFile::class)) {
return [
'text' => '',
'flags' => ['ocr_provider_missing:ocrspace', 'ocr_transport_missing:curl'],
];
}
$url = trim((string) ($providerConfig['url'] ?? ''));
if ($url === '') {
return [
'text' => '',
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_url_missing'],
];
}
$apiKey = trim((string) ($providerConfig['api_key'] ?? ''));
if ($apiKey === '') {
return [
'text' => '',
'flags' => ['ocr_provider_missing:ocrspace', 'ocrspace_api_key_missing'],
];
}
$postFields = [
'file' => new \CURLFile($imagePath),
'language' => (string) ($providerConfig['language'] ?? 'eng'),
'OCREngine' => (string) ((int) ($providerConfig['engine'] ?? 2)),
'scale' => (string) ($providerConfig['scale'] ?? 'true'),
'detectOrientation' => (string) ($providerConfig['detect_orientation'] ?? 'true'),
'isTable' => (string) ($providerConfig['is_table'] ?? 'false'),
'isOverlayRequired' => 'false',
];
$ch = curl_init();
curl_setopt_array($ch, [
CURLOPT_URL => $url,
CURLOPT_POST => true,
CURLOPT_POSTFIELDS => $postFields,
CURLOPT_RETURNTRANSFER => true,
CURLOPT_FOLLOWLOCATION => true,
CURLOPT_TIMEOUT => max(5, (int) ($providerConfig['timeout'] ?? 25)),
CURLOPT_HTTPHEADER => [
'Accept: application/json',
'apikey: ' . $apiKey,
],
]);
$response = curl_exec($ch);
$curlError = curl_error($ch);
$httpStatus = (int) curl_getinfo($ch, CURLINFO_RESPONSE_CODE);
curl_close($ch);
if ($response === false || $curlError !== '') {
return [
'text' => '',
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_request_failed'],
];
}
$payload = json_decode((string) $response, true);
if (!is_array($payload)) {
return [
'text' => '',
'flags' => ['ocr_provider_empty:ocrspace', 'ocrspace_invalid_response'],
];
}
$flags = [];
$rawText = '';
$parsedResults = $payload['ParsedResults'] ?? null;
if (is_array($parsedResults)) {
$texts = [];
foreach ($parsedResults as $result) {
if (!is_array($result)) {
continue;
}
$fileExitCode = (string) ($result['FileParseExitCode'] ?? '');
if ($fileExitCode !== '') {
$flags[] = 'ocrspace_file_exit_code:' . $fileExitCode;
}
$parsedText = trim((string) ($result['ParsedText'] ?? ''));
if ($parsedText !== '') {
$texts[] = $parsedText;
}
$resultError = trim((string) ($result['ErrorMessage'] ?? ''));
if ($resultError !== '') {
$flags[] = 'ocrspace_result_error';
}
}
$rawText = trim(implode("\n", $texts));
}
$ocrExitCode = (string) ($payload['OCRExitCode'] ?? '');
$isErroredOnProcessing = !empty($payload['IsErroredOnProcessing']);
$errorMessage = trim((string) ($payload['ErrorMessage'] ?? ''));
$errorDetails = trim((string) ($payload['ErrorDetails'] ?? ''));
if ($httpStatus >= 400) {
$flags[] = 'ocrspace_http_error';
}
if ($ocrExitCode !== '') {
$flags[] = 'ocrspace_exit_code:' . $ocrExitCode;
}
$flags[] = 'ocrspace_engine:' . (string) ((int) ($providerConfig['engine'] ?? 2));
if ($isErroredOnProcessing) {
$flags[] = 'ocrspace_processing_error';
}
if ($errorMessage !== '' || $errorDetails !== '') {
$flags[] = 'ocrspace_error';
}
return [
'text' => $rawText,
'flags' => $rawText === '' ? array_values(array_unique(array_merge($flags, ['ocr_provider_empty:ocrspace']))) : array_values(array_unique($flags)),
];
}
private function runTesseract(array $providerConfig, string $imagePath): array
{
$binary = (string) ($providerConfig['binary'] ?? 'tesseract');
if (!$this->binaryExists($binary)) {
return [
'text' => '',
'flags' => ['ocr_provider_missing:tesseract'],
];
}
$language = (string) ($providerConfig['language'] ?? 'eng');
$tmpBase = tempnam(sys_get_temp_dir(), 'mc-ocr-');
if ($tmpBase === false) {
return [
'text' => '',
'flags' => ['ocr_tempfile_failed:tesseract'],
];
}
@unlink($tmpBase);
$command = sprintf(
'%s %s %s -l %s 2>/dev/null',
escapeshellcmd($binary),
escapeshellarg($imagePath),
escapeshellarg($tmpBase),
escapeshellarg($language)
);
shell_exec($command);
$txtFile = $tmpBase . '.txt';
$text = is_file($txtFile) ? (string) file_get_contents($txtFile) : '';
@unlink($txtFile);
return [
'text' => trim($text),
'flags' => trim($text) === '' ? ['ocr_provider_empty:tesseract'] : [],
];
}
private function binaryExists(string $binary): bool
{
return $binary !== '' && trim((string) shell_exec('command -v ' . escapeshellarg($binary) . ' 2>/dev/null')) !== '';
}
private function parseText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array
{
$measurement = $this->parseMeasurementText($rawText, $dateContext);
$wallet = $this->parseWalletText($rawText, $dateContext, $walletCurrencyHint);
$isWallet = ($wallet['score'] ?? 0) > ($measurement['score'] ?? 0)
&& (
($wallet['suggested_wallet']['wallet_balance'] ?? null) !== null
|| ($wallet['suggested_wallet']['total_value_amount'] ?? null) !== null
);
return [
'kind' => $isWallet ? 'wallet' : 'measurement',
'suggested' => $measurement['suggested'],
'suggested_wallet' => $wallet['suggested_wallet'],
'confidence' => round((float) ($isWallet ? ($wallet['confidence'] ?? 0.0) : ($measurement['confidence'] ?? 0.0)), 4),
'flags' => $isWallet ? $wallet['flags'] : $measurement['flags'],
];
}
private function parseMeasurementText(string $rawText, string $dateContext): array
{
$flags = [];
$suggestedTime = null;
$coinsTotal = null;
$price = null;
$currency = null;
$normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: '';
$lines = array_values(array_filter(array_map(
static fn (string $line): string => trim($line),
preg_split('/\R/u', $rawText) ?: []
), static fn (string $line): bool => $line !== ''));
if ($normalizedText === '') {
$flags[] = 'ocr_raw_text_empty';
}
if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) {
$suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]);
}
preg_match_all('/\b\d+(?:[.,]\d+)?\b/', $normalizedText, $numberMatches);
$decimalCandidates = [];
foreach ($numberMatches[0] ?? [] as $candidate) {
$normalized = (float) str_replace(',', '.', $candidate);
if ($normalized <= 0) {
continue;
}
$decimalCandidates[] = [
'raw' => $candidate,
'value' => $normalized,
'precision' => str_contains($candidate, ',') || str_contains($candidate, '.')
? strlen((string) preg_replace('/^\d+[.,]/', '', $candidate))
: 0,
];
}
if (preg_match('/DOGE\s*\/\s*(USD|EUR|USDT|USDC|BTC|ETH|LTC)/i', $normalizedText, $pairMatch)) {
$currency = strtoupper((string) $pairMatch[1]);
} elseif (preg_match('/\b(EUR|USD|USDT|USDC|BTC|ETH|LTC)\b/i', $normalizedText, $currencyMatch)) {
$currency = strtoupper((string) $currencyMatch[1]);
} elseif (str_contains($normalizedText, '$')) {
$currency = 'USD';
} else {
$flags[] = 'currency_missing';
}
if (preg_match('/DOGE\s*\/\s*(?:USD|EUR|USDT|USDC|BTC|ETH|LTC)[^\d]{0,20}(\d+[.,]\d{3,8})/i', $normalizedText, $priceMatch)) {
$price = round((float) str_replace(',', '.', $priceMatch[1]), 8);
}
if ($coinsTotal === null) {
foreach ($lines as $line) {
if (!preg_match('/MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE/i', $line)) {
continue;
}
if (preg_match('/(\d+[.,]\d{4,8})/', $line, $lineCoinsMatch)) {
$coinsTotal = round((float) str_replace(',', '.', $lineCoinsMatch[1]), 6);
$flags[] = 'coins_from_balance_line';
break;
}
}
}
if ($coinsTotal === null && preg_match('/(\d+[.,]\d{4,8})\s*(?:DOGE)?\s*(?:MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE)/i', $normalizedText, $coinsMatch)) {
$coinsTotal = round((float) str_replace(',', '.', $coinsMatch[1]), 6);
$flags[] = 'coins_from_balance_context';
}
if ($coinsTotal === null) {
$coinsCandidates = array_values(array_filter($decimalCandidates, static function (array $item) use ($price): bool {
if ($item['precision'] < 4) {
return false;
}
if ($item['value'] <= 0 || $item['value'] >= 1000000) {
return false;
}
if ($price !== null && abs($item['value'] - $price) < 0.0000005) {
return false;
}
return true;
}));
if ($coinsCandidates !== []) {
usort($coinsCandidates, static function (array $a, array $b): int {
return [$b['precision'], $b['value']] <=> [$a['precision'], $a['value']];
});
$coinsTotal = round((float) $coinsCandidates[0]['value'], 6);
if (count($coinsCandidates) > 1) {
$flags[] = 'coins_ambiguous';
}
} else {
$flags[] = 'coins_missing';
}
}
$priceCandidates = array_values(array_filter(
$decimalCandidates,
static fn (array $item): bool => $item['value'] > 0 && $item['value'] < 1
));
if ($price === null && $priceCandidates !== []) {
usort($priceCandidates, static function (array $a, array $b): int {
return [$b['precision'], $a['value']] <=> [$a['precision'], $b['value']];
});
$price = round((float) $priceCandidates[0]['value'], 8);
if (count($priceCandidates) > 1 && count(array_filter($priceCandidates, static fn (array $item): bool => $item['precision'] >= 4)) > 1) {
$flags[] = 'price_ambiguous';
}
}
if ($price === null && $coinsTotal !== null && preg_match('/~\s*(\d+[.,]\d+)\s*\$/', $normalizedText, $fiatMatch)) {
$fiatValue = (float) str_replace(',', '.', $fiatMatch[1]);
if ($fiatValue > 0) {
$price = round($fiatValue / $coinsTotal, 8);
$flags[] = 'price_derived_from_balance_value';
$currency = $currency ?? 'USD';
}
}
$measurementIndicators = 0;
$normalizedLower = strtolower($normalizedText);
foreach (['mining-guthaben', 'mining guthaben', 'mining-balance', 'mining balance', 'doge /', 'bonus', 'verlauf'] as $indicator) {
if (str_contains($normalizedLower, $indicator)) {
$measurementIndicators++;
}
}
$matchedFields = 0;
foreach ([$coinsTotal, $price, $currency] as $field) {
if ($field !== null) {
$matchedFields++;
}
}
$score = $matchedFields + min(3, $measurementIndicators);
$confidence = max(0.05, min(0.99, ($matchedFields / 3) + (min(3, $measurementIndicators) * 0.08) - (count($flags) * 0.04)));
return [
'suggested' => [
'measured_at' => $suggestedTime,
'coins_total' => $coinsTotal,
'price_per_coin' => $price,
'price_currency' => $currency,
'note' => null,
'source' => 'image_ocr',
],
'confidence' => round($confidence, 4),
'flags' => $flags,
'score' => $score,
];
}
private function parseWalletText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array
{
$flags = [];
$suggestedTime = null;
$totalValueAmount = null;
$totalValueCurrency = null;
$walletBalance = null;
$walletCurrency = $walletCurrencyHint !== '' ? $walletCurrencyHint : 'DOGE';
$balances = [];
$normalizedText = preg_replace('/[[:space:]]+/u', ' ', trim($rawText)) ?: '';
$lines = array_values(array_filter(array_map(
static fn (string $line): string => trim($line),
preg_split('/\R/u', $rawText) ?: []
), static fn (string $line): bool => $line !== ''));
if (preg_match('/\b([01]?\d|2[0-3]):([0-5]\d)\b/', $normalizedText, $timeMatch)) {
$suggestedTime = sprintf('%s %02d:%02d:00', $dateContext, (int) $timeMatch[1], (int) $timeMatch[2]);
}
if (
preg_match('/GESAMTSALDO[^\d]{0,24}(\d+(?:[.,]\d+)?)\s*(USD|EUR|USDT|USDC|BTC|ETH|DOGE|CTC|HSH)/i', $normalizedText, $totalMatch)
|| preg_match('/(\d+(?:[.,]\d+)?)\s*(USD|EUR)\b.*GESAMTSALDO/i', $normalizedText, $totalMatch)
) {
$totalValueAmount = round((float) str_replace(',', '.', $totalMatch[1]), 8);
$totalValueCurrency = strtoupper((string) $totalMatch[2]);
} else {
$flags[] = 'wallet_total_missing';
}
$assetRows = [];
foreach ($lines as $lineIndex => $line) {
if (!preg_match('/(\d+(?:[.,]\d+)?)\s*([A-Z]{2,10})\b/u', $line, $match)) {
continue;
}
$amount = round((float) str_replace(',', '.', $match[1]), 10);
$currency = strtoupper((string) $match[2]);
if ($amount <= 0 || $currency === '' || in_array($currency, ['USD', 'EUR'], true)) {
continue;
}
$assetRows[] = [
'index' => $lineIndex,
'currency' => $currency,
'balance' => $amount,
];
}
foreach ($assetRows as $assetIndex => $assetRow) {
$currency = (string) $assetRow['currency'];
$balanceAmount = (float) $assetRow['balance'];
$startIndex = (int) $assetRow['index'];
$endIndex = isset($assetRows[$assetIndex + 1]['index'])
? (int) $assetRows[$assetIndex + 1]['index']
: count($lines);
$usdCandidates = [];
for ($i = $startIndex; $i < $endIndex; $i++) {
if (!isset($lines[$i])) {
continue;
}
if (preg_match_all('/(\d+(?:[.,]\d+)?)\s*USD\b/u', $lines[$i], $usdMatches, PREG_SET_ORDER)) {
foreach ($usdMatches as $usdMatch) {
$usdCandidates[] = round((float) str_replace(',', '.', $usdMatch[1]), 8);
}
}
}
$priceAmount = $this->pickWalletUnitPrice(
$balanceAmount,
$usdCandidates,
$totalValueCurrency === 'USD' ? $totalValueAmount : null
);
$balances[$currency] = [
'balance' => $balanceAmount,
'price_amount' => $priceAmount,
'price_currency' => $priceAmount !== null ? 'USD' : null,
];
}
if ($walletCurrencyHint !== '' && array_key_exists($walletCurrencyHint, $balances)) {
$walletCurrency = $walletCurrencyHint;
$walletBalance = is_array($balances[$walletCurrencyHint])
? (float) ($balances[$walletCurrencyHint]['balance'] ?? 0.0)
: (float) $balances[$walletCurrencyHint];
} elseif ($balances !== []) {
foreach (['DOGE', 'BTC', 'ETH', 'CTC', 'HSH', 'LTC', 'USDT', 'USDC'] as $preferredCurrency) {
if (array_key_exists($preferredCurrency, $balances)) {
$walletCurrency = $preferredCurrency;
$walletBalance = is_array($balances[$preferredCurrency])
? (float) ($balances[$preferredCurrency]['balance'] ?? 0.0)
: (float) $balances[$preferredCurrency];
break;
}
}
if ($walletBalance === null) {
$firstCurrency = array_key_first($balances);
if (is_string($firstCurrency)) {
$walletCurrency = $firstCurrency;
$walletBalance = is_array($balances[$firstCurrency])
? (float) ($balances[$firstCurrency]['balance'] ?? 0.0)
: (float) $balances[$firstCurrency];
}
}
} else {
$flags[] = 'wallet_balance_missing';
}
$walletIndicators = 0;
$normalizedLower = strtolower($normalizedText);
foreach (['wallets', 'gesamtsaldo', 'alle münzen', 'alle munzen', 'letzte transaktion'] as $indicator) {
if (str_contains($normalizedLower, $indicator)) {
$walletIndicators++;
}
}
$matchedFields = 0;
foreach ([$totalValueAmount, $walletBalance, $walletCurrency] as $field) {
if ($field !== null && $field !== '') {
$matchedFields++;
}
}
$score = $matchedFields + ($walletIndicators * 2);
$confidence = max(0.05, min(0.99, ($matchedFields / 3) + (min(3, $walletIndicators) * 0.12) - (count($flags) * 0.03)));
ksort($balances);
return [
'suggested_wallet' => [
'measured_at' => $suggestedTime,
'total_value_amount' => $totalValueAmount,
'total_value_currency' => $totalValueCurrency,
'wallet_balance' => $walletBalance,
'wallet_currency' => $walletCurrency,
'balances_json' => $balances,
'note' => null,
'source' => 'image_ocr',
],
'confidence' => round($confidence, 4),
'flags' => array_values(array_unique($flags)),
'score' => $score,
];
}
/**
* @param list<float|int> $candidates
*/
private function pickWalletUnitPrice(float $balance, array $candidates, ?float $walletTotal = null): ?float
{
$candidates = array_values(array_filter(array_map(
static fn (mixed $value): float => round((float) $value, 8),
$candidates
), static fn (float $value): bool => $value > 0));
if ($balance <= 0 || $candidates === []) {
return null;
}
if (count($candidates) === 1) {
$candidate = $candidates[0];
if ($walletTotal !== null && $walletTotal > 0 && ($balance * $candidate) > ($walletTotal * 1.5)) {
return round($candidate / $balance, 8);
}
return $candidate;
}
$bestPrice = null;
$bestError = null;
$candidateCount = count($candidates);
for ($i = 0; $i < $candidateCount; $i++) {
$priceCandidate = $candidates[$i];
for ($j = 0; $j < $candidateCount; $j++) {
if ($i === $j) {
continue;
}
$totalCandidate = $candidates[$j];
$estimatedTotal = $balance * $priceCandidate;
$denominator = max(abs($totalCandidate), 0.00000001);
$error = abs($estimatedTotal - $totalCandidate) / $denominator;
if ($bestError === null || $error < $bestError) {
$bestError = $error;
$bestPrice = $priceCandidate;
}
}
}
if ($bestPrice !== null && $bestError !== null && $bestError <= 0.2) {
return round($bestPrice, 8);
}
return round($candidates[count($candidates) - 1], 8);
}
}

View File

@@ -0,0 +1,79 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
final class SeedData
{
public static function projectKey(): string
{
return 'doge-main';
}
public static function projectName(): string
{
return 'DOGE Mining Main';
}
public static function settings(): array
{
return [
'baseline_measured_at' => '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],
];
}
}

View File

@@ -0,0 +1,62 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Domain;
use Modules\MiningChecker\Infrastructure\MiningRepository;
final class SeedImporter
{
private MiningRepository $repository;
public function __construct(MiningRepository $repository)
{
$this->repository = $repository;
}
public function import(string $projectKey): array
{
$seedProjectKey = SeedData::projectKey();
if ($projectKey !== $seedProjectKey) {
return ['inserted' => 0, 'project_key' => $projectKey, 'warning' => 'Seed-Daten sind nur fuer doge-main definiert.'];
}
$this->repository->ensureProject($projectKey, SeedData::projectName());
$this->repository->saveSettings($projectKey, SeedData::settings());
$insertedMeasurements = 0;
foreach (SeedData::measurements() as $measurement) {
try {
$this->repository->createMeasurement($projectKey, array_merge([
'image_path' => null,
'ocr_raw_text' => null,
'ocr_confidence' => null,
'ocr_flags' => null,
], $measurement));
$insertedMeasurements++;
} catch (\Throwable $exception) {
// Duplicate seeds are expected on repeated imports.
}
}
$targetCount = 0;
foreach (SeedData::targets() as $target) {
$this->repository->saveTarget($projectKey, $target);
$targetCount++;
}
$dashboardCount = 0;
foreach (SeedData::dashboards() as $dashboard) {
$this->repository->saveDashboard($projectKey, $dashboard);
$dashboardCount++;
}
return [
'project_key' => $projectKey,
'imported_measurements' => $insertedMeasurements,
'historical_rows_total' => count(SeedData::measurements()),
'targets_synced' => $targetCount,
'dashboards_synced' => $dashboardCount,
];
}
}

View File

@@ -0,0 +1,65 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Infrastructure;
use App\Database as AppDatabase;
use Modules\MiningChecker\Support\ApiException;
use PDO;
final class ConnectionFactory
{
public static function make(ModuleConfig $config): PDO
{
$moduleSettings = modules()->settings('mining-checker');
$useSeparateDb = self::usesSeparateDatabase($moduleSettings);
if ($useSeparateDb) {
$dbConfig = is_array($moduleSettings['db'] ?? null) ? $moduleSettings['db'] : [];
if ($dbConfig === []) {
throw new ApiException('Custom-Datenbank ist aktiviert, aber nicht vollstaendig konfiguriert.', 500);
}
self::assertSupportedDriver($dbConfig);
if (method_exists(AppDatabase::class, 'connectFromConfig')) {
return AppDatabase::connectFromConfig($dbConfig);
}
return AppDatabase::createFromArray($dbConfig);
}
$dbConfig = app()->config()->dbConfig;
if ($dbConfig === []) {
throw new ApiException('Projekt-Datenbankkonfiguration fehlt in config/db_settings_basic.php.', 500);
}
self::assertSupportedDriver($dbConfig);
if (method_exists(AppDatabase::class, 'connectFromConfig')) {
return AppDatabase::connectFromConfig($dbConfig);
}
return AppDatabase::createFromArray($dbConfig);
}
private static function usesSeparateDatabase(array $moduleSettings): bool
{
$raw = $moduleSettings['use_separate_db'] ?? false;
if (is_bool($raw)) {
return $raw;
}
$normalized = strtolower(trim((string) $raw));
return in_array($normalized, ['1', 'true', 'yes', 'on', 'custom'], true);
}
private static function assertSupportedDriver(array $dbConfig): void
{
$driver = strtolower((string) ($dbConfig['driver'] ?? ($dbConfig['dsn'] ?? '')));
if ($driver !== '' && !in_array($driver, ['mysql', 'pgsql'], true) && !str_starts_with($driver, 'mysql:') && !str_starts_with($driver, 'pgsql:')) {
throw new ApiException(
'Mining-Checker unterstuetzt aktuell MySQL/MariaDB und PostgreSQL. Stelle den Driver auf mysql oder pgsql.',
500,
['driver' => $dbConfig['driver'] ?? 'unknown']
);
}
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,66 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Infrastructure;
final class ModuleConfig
{
private array $config;
public function __construct(array $config)
{
$this->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'));
}
}

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,191 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Legacy;
use RuntimeException;
final class LegacyModuleStore
{
public function __construct(
private readonly string $projectRoot,
private readonly string $userScope,
) {
}
/**
* @return array<string, mixed>
*/
public function loadProject(string $projectKey): array
{
$state = $this->loadAll();
$projects = is_array($state['projects'] ?? null) ? $state['projects'] : [];
if (!isset($projects[$projectKey]) || !is_array($projects[$projectKey])) {
$projects[$projectKey] = $this->defaultProjectState($projectKey);
$state['projects'] = $projects;
$this->saveAll($state);
}
return array_replace_recursive($this->defaultProjectState($projectKey), $projects[$projectKey]);
}
/**
* @param array<string, mixed> $projectState
*/
public function saveProject(string $projectKey, array $projectState): void
{
$state = $this->loadAll();
$projects = is_array($state['projects'] ?? null) ? $state['projects'] : [];
$projects[$projectKey] = array_replace_recursive($this->defaultProjectState($projectKey), $projectState);
$state['projects'] = $projects;
$this->saveAll($state);
}
/**
* @return array<string, mixed>
*/
public function loadAuth(): array
{
$state = $this->loadAll();
$auth = is_array($state['module_auth'] ?? null) ? $state['module_auth'] : [];
return array_replace_recursive([
'required' => true,
'users' => [],
'groups' => [],
], $auth);
}
/**
* @param array<string, mixed> $auth
*/
public function saveAuth(array $auth): void
{
$state = $this->loadAll();
$state['module_auth'] = [
'required' => (bool) ($auth['required'] ?? true),
'users' => array_values(array_filter(array_map('strval', (array) ($auth['users'] ?? [])))),
'groups' => array_values(array_filter(array_map('strval', (array) ($auth['groups'] ?? [])))),
];
$this->saveAll($state);
}
public function nextId(string $projectKey, string $bucket): int
{
$project = $this->loadProject($projectKey);
$counters = is_array($project['_counters'] ?? null) ? $project['_counters'] : [];
$next = (int) ($counters[$bucket] ?? 0) + 1;
$counters[$bucket] = $next;
$project['_counters'] = $counters;
$this->saveProject($projectKey, $project);
return $next;
}
/**
* @return array<string, mixed>
*/
private function loadAll(): array
{
$path = $this->path();
if (!is_file($path)) {
return [
'module_auth' => [
'required' => true,
'users' => [],
'groups' => [],
],
'projects' => [],
];
}
$raw = file_get_contents($path);
if ($raw === false || trim($raw) === '') {
return [
'module_auth' => [
'required' => true,
'users' => [],
'groups' => [],
],
'projects' => [],
];
}
$data = json_decode($raw, true);
return is_array($data) ? $data : [
'module_auth' => [
'required' => true,
'users' => [],
'groups' => [],
],
'projects' => [],
];
}
/**
* @param array<string, mixed> $state
*/
private function saveAll(array $state): void
{
$directory = dirname($this->path());
if (!is_dir($directory) && !mkdir($directory, 0775, true) && !is_dir($directory)) {
throw new RuntimeException('Mining-Checker Speicherverzeichnis konnte nicht erstellt werden.');
}
$json = json_encode($state, JSON_PRETTY_PRINT | JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
if ($json === false) {
throw new RuntimeException('Mining-Checker Speicherinhalt konnte nicht serialisiert werden.');
}
if (file_put_contents($this->path(), $json . PHP_EOL, LOCK_EX) === false) {
throw new RuntimeException('Mining-Checker Speicherinhalt konnte nicht geschrieben werden.');
}
}
private function path(): string
{
$safeScope = preg_replace('/[^a-zA-Z0-9_-]+/', '-', $this->userScope) ?: 'guest';
return $this->projectRoot . '/data/mining-checker/users/' . $safeScope . '.json';
}
/**
* @return array<string, mixed>
*/
private function defaultProjectState(string $projectKey): array
{
return [
'_counters' => [],
'project' => [
'project_key' => $projectKey,
'project_name' => strtoupper(str_replace('-', ' ', $projectKey)),
],
'settings' => [
'baseline_measured_at' => gmdate('Y-m-d H:i:s'),
'baseline_coins_total' => 0,
'daily_cost_amount' => 0,
'daily_cost_currency' => 'EUR',
'report_currency' => 'EUR',
'crypto_currency' => 'DOGE',
'display_timezone' => 'Europe/Berlin',
'fx_max_age_hours' => 3,
'module_theme_mode' => 'inherit',
'module_theme_accent' => 'teal',
'preferred_currencies' => ['DOGE', 'USD', 'EUR'],
],
'measurements' => [],
'targets' => [],
'dashboards' => [],
'wallet_snapshots' => [],
'cost_plans' => [],
'payouts' => [],
'miner_offers' => [],
'purchased_miners' => [],
'fx_history' => [],
];
}
}

View File

@@ -0,0 +1,15 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Legacy;
use ModulesCore\ModuleHttp;
final class UserScope
{
public static function current(): string
{
return ModuleHttp::currentUserScope();
}
}

View File

@@ -0,0 +1,29 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Support;
use RuntimeException;
final class ApiException extends RuntimeException
{
private int $statusCode;
private array $context;
public function __construct(string $message, int $statusCode = 400, array $context = [])
{
parent::__construct($message);
$this->statusCode = $statusCode;
$this->context = $context;
}
public function statusCode(): int
{
return $this->statusCode;
}
public function context(): array
{
return $this->context;
}
}

View File

@@ -0,0 +1,35 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Support;
final class DebugState
{
private static array $trace = [];
private static ?string $latestFilePath = null;
public static function replace(array $trace): void
{
self::$trace = $trace;
}
public static function export(): array
{
return self::$trace;
}
public static function clear(): void
{
self::$trace = [];
}
public static function setLatestFilePath(?string $filePath): void
{
self::$latestFilePath = $filePath;
}
public static function latestFilePath(): ?string
{
return self::$latestFilePath;
}
}

View File

@@ -0,0 +1,60 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Support;
final class DebugTrace
{
private bool $enabled;
private array $entries = [];
private ?string $filePath;
public function __construct(bool $enabled = false, ?string $filePath = null)
{
$this->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));
}
}

View File

@@ -0,0 +1,27 @@
<?php
declare(strict_types=1);
namespace Modules\MiningChecker\Support;
final class Http
{
public static function json(array $payload, int $statusCode = 200): never
{
http_response_code($statusCode);
header('Content-Type: application/json; charset=utf-8');
echo json_encode($payload, JSON_UNESCAPED_UNICODE | JSON_UNESCAPED_SLASHES);
exit;
}
public static function input(): array
{
$contentType = $_SERVER['CONTENT_TYPE'] ?? '';
if (str_contains($contentType, 'application/json')) {
$raw = file_get_contents('php://input');
$data = json_decode($raw ?: '[]', true);
return is_array($data) ? $data : [];
}
return $_POST;
}
}