From 650a0bd11a3377965825b3b7dc24d0b7032a7adb Mon Sep 17 00:00:00 2001 From: Lars Gebhardt-Kusche Date: Mon, 22 Jun 2026 00:12:32 +0200 Subject: [PATCH] sdsf --- modules/mining-checker/assets/css/app.css | 34 ++++ modules/mining-checker/assets/js/app.js | 172 +++++++++++++++--- modules/mining-checker/design.json | 2 +- modules/mining-checker/sql/schema.mysql.sql | 14 ++ modules/mining-checker/sql/schema.pgsql.sql | 16 ++ modules/mining-checker/sql/schema.sql | 14 ++ modules/mining-checker/src/Api/Router.php | 49 ++++- .../src/Domain/AnalyticsService.php | 20 +- .../src/Infrastructure/MiningRepository.php | 70 +++++++ .../src/Infrastructure/SchemaManager.php | 56 ++++++ 10 files changed, 413 insertions(+), 34 deletions(-) diff --git a/modules/mining-checker/assets/css/app.css b/modules/mining-checker/assets/css/app.css index 5386cfd5..fa716a55 100644 --- a/modules/mining-checker/assets/css/app.css +++ b/modules/mining-checker/assets/css/app.css @@ -179,6 +179,40 @@ min-width: 240px; } +#mining-checker-app .mc-history-stack { + display: grid; + gap: 14px; +} + +#mining-checker-app .mc-history-card { + display: grid; + gap: 14px; + padding: 18px; + border: 1px solid var(--mc-line); + border-radius: 16px; + background: var(--mc-surface); + box-shadow: 0 10px 24px rgba(15, 23, 42, 0.06); +} + +#mining-checker-app .mc-history-asset-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(180px, 1fr)); + gap: 12px; +} + +#mining-checker-app .mc-history-asset-card { + display: grid; + gap: 6px; + padding: 12px 14px; + border: 1px solid var(--mc-line); + border-radius: 14px; + background: #fff; +} + +#mining-checker-app .mc-history-asset-card strong { + color: var(--mc-text); +} + #mining-checker-app .mc-asset-row { display: grid; gap: 2px; diff --git a/modules/mining-checker/assets/js/app.js b/modules/mining-checker/assets/js/app.js index b02a9f65..5ddc3c9f 100644 --- a/modules/mining-checker/assets/js/app.js +++ b/modules/mining-checker/assets/js/app.js @@ -305,7 +305,7 @@ return 'Historie der letzten Uploads mit Performance- und Trendwerten.'; } if (sectionId === 'wallet') { - return 'Wallet-Snapshots, Transfers und erkannte Asset-Bestände auswerten.'; + return 'NC-Wallet, Transfers, echte Wallet-Auszahlungen und erkannte Asset-Bestaende auswerten.'; } if (sectionId === 'mining') { return 'Miner, Angebote und operative Daten verwalten.'; @@ -472,6 +472,7 @@ cost_plans: [], currencies: [], payouts: [], + wallet_withdrawals: [], miner_offers: [], purchased_miners: [], measurement_rates: [], @@ -922,6 +923,13 @@ note: '', }); const [payoutModalOpen, setPayoutModalOpen] = useState(false); + const [walletWithdrawalForm, setWalletWithdrawalForm] = useState({ + withdrawal_at: '', + coins_amount: '', + withdrawal_currency: 'DOGE', + note: '', + }); + const [walletWithdrawalModalOpen, setWalletWithdrawalModalOpen] = useState(false); const [minerOfferForm, setMinerOfferForm] = useState({ label: '', runtime_months: '', @@ -982,6 +990,7 @@ ]; const currentCostPlans = Array.isArray(currentSettings.cost_plans) ? currentSettings.cost_plans : []; const currentPayouts = Array.isArray(currentSettings.payouts) ? currentSettings.payouts : []; + const currentWalletWithdrawals = Array.isArray(currentSettings.wallet_withdrawals) ? currentSettings.wallet_withdrawals : []; const currentWalletSnapshots = Array.isArray(payload?.wallet_snapshots) ? payload.wallet_snapshots : []; const currentMinerOffers = Array.isArray(currentSettings.miner_offers) ? currentSettings.miner_offers : []; const currentPurchasedMiners = Array.isArray(currentSettings.purchased_miners) ? currentSettings.purchased_miners : []; @@ -1946,6 +1955,61 @@ } } + async function submitWalletWithdrawal(event) { + event.preventDefault(); + setSaving(true); + setError(''); + setMessage(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/wallet-withdrawals`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(walletWithdrawalForm), + timeoutMs: 8000, + }); + invalidateProjectBootstrapCache(projectKey); + setMessage('Wallet-Auszahlung gespeichert.'); + setWalletWithdrawalForm({ + withdrawal_at: '', + coins_amount: '', + withdrawal_currency: currentSettings.crypto_currency || 'DOGE', + note: '', + }); + setWalletWithdrawalModalOpen(false); + await reloadBootstrapAfterMutation('Wallet-Auszahlung gespeichert.'); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + + async function deleteWalletWithdrawal(withdrawal) { + if (!withdrawal || !withdrawal.id) { + return; + } + + const label = `${fmtNumber(withdrawal.coins_amount, 6)} ${withdrawal.withdrawal_currency || ''}`.trim(); + if (!window.confirm(`Diese Wallet-Auszahlung (${label}) wirklich loeschen?`)) { + return; + } + + setSaving(true); + setError(''); + setMessage(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/wallet-withdrawals/${encodeURIComponent(withdrawal.id)}`, { + method: 'DELETE', + }); + invalidateProjectBootstrapCache(projectKey); + await reloadBootstrapAfterMutation('Wallet-Auszahlung geloescht.'); + } catch (err) { + setError(err.message); + } finally { + setSaving(false); + } + } + async function submitMinerOffer(event) { event.preventDefault(); setSaving(true); @@ -2637,7 +2701,7 @@ : []; return h('div', { className: 'mc-stack' }, [ - panel('Wallet-Bestaende', 'Der letzte Wallet-Snapshot zeigt alle erkannten Assets separat.', latestWalletAssets.length + panel('NC Wallet-Bestaende', 'Der letzte Snapshot zeigt alle erkannten Assets des internen NC Wallet separat.', latestWalletAssets.length ? h('div', { className: 'mc-asset-grid' }, latestWalletAssets.map(([code, asset]) => { const balance = asset && typeof asset === 'object' ? asset.balance : asset; const priceAmount = asset && typeof asset === 'object' ? asset.price_amount : null; @@ -2651,33 +2715,6 @@ ]); })) : h('div', { className: 'mc-empty' }, 'Noch keine Wallet-Assets erkannt.')), - panel('Wallet-Historie', 'Die letzten 10 Wallet-Uploads mit allen aus dem Screenshot gelesenen Assets.', h('div', { className: 'mc-table-shell' }, [ - h('table', { key: 'wallet-table', className: 'mc-table' }, [ - h('thead', { key: 'thead' }, h('tr', null, [ - 'Zeit', 'Quelle', 'Assets' - ].map((label) => h('th', { key: label }, label)))), - h('tbody', { key: 'tbody' }, - currentWalletSnapshots.length - ? currentWalletSnapshots.slice(0, 10).map((row) => h('tr', { key: row.id }, [ - h('td', { key: 'measured' }, fmtDate(row.measured_at)), - h('td', { key: 'source' }, row.source || 'manual'), - h('td', { key: 'assets' }, h('div', { className: 'mc-asset-list' }, Object.entries(row.balances_json || {}).map(([code, asset]) => { - const balance = asset && typeof asset === 'object' ? asset.balance : asset; - const priceAmount = asset && typeof asset === 'object' ? asset.price_amount : null; - const priceCurrency = asset && typeof asset === 'object' ? asset.price_currency : null; - return h('div', { key: code, className: 'mc-asset-row' }, [ - h('strong', { key: 'code' }, code), - h('span', { key: 'balance' }, `${fmtNumber(balance, 8)} ${code}`), - priceAmount !== null && priceAmount !== undefined - ? h('span', { key: 'price', className: 'mc-text' }, `1 ${code} = ${fmtNumber(priceAmount, 6)} ${priceCurrency || ''}`.trim()) - : null, - ]); - }))), - ])) - : [h('tr', { key: 'empty' }, h('td', { colSpan: 3 }, 'Noch keine Wallet-Snapshots gespeichert.'))] - ), - ]), - ])), panel('Wallet-Transfers', 'Transfers zum hier verfolgten Wallet erhoehen den Wallet-Bestand und reduzieren den sichtbaren Miner-Bestand. Reinvestierte Krypto-Minerkaeufe werden davon automatisch wieder abgezogen.', [ h('div', { key: 'actions', className: 'mc-inline-row' }, [ h('button', { @@ -2717,6 +2754,71 @@ ]), ]), ]), + panel('Wallet-Auszahlungen', 'Echte Auszahlungen vom NC Wallet an ein externes, hier nicht getracktes Wallet reduzieren den NC-Wallet-Bestand.', [ + h('div', { key: 'actions', className: 'mc-inline-row' }, [ + h('button', { + key: 'add-wallet-withdrawal', + type: 'button', + className: 'mc-button mc-button--secondary', + onClick: () => { + setWalletWithdrawalForm((previous) => ({ + withdrawal_at: previous.withdrawal_at || nowDateTimeLocalValue(), + coins_amount: previous.coins_amount || '', + withdrawal_currency: currentCoinCurrency, + note: previous.note || '', + })); + setWalletWithdrawalModalOpen(true); + }, + }, 'Aus NC Wallet auszahlen'), + ]), + h('div', { key: 'withdrawal-list', className: 'mc-table-shell' }, [ + h('table', { key: 'withdrawal-table', className: 'mc-table' }, [ + h('thead', { key: 'head' }, h('tr', null, ['Zeit', 'Coins', 'Waehrung', 'Notiz', 'Aktion'].map((label) => h('th', { key: label }, label)))), + h('tbody', { key: 'body' }, + currentWalletWithdrawals.length + ? currentWalletWithdrawals.slice().reverse().map((withdrawal) => h('tr', { key: withdrawal.id }, [ + h('td', { key: 'time' }, fmtDate(withdrawal.withdrawal_at)), + h('td', { key: 'coins' }, fmtNumber(withdrawal.coins_amount, 6)), + h('td', { key: 'currency' }, withdrawal.withdrawal_currency), + h('td', { key: 'note' }, withdrawal.note || '—'), + h('td', { key: 'action' }, h('button', { + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: () => deleteWalletWithdrawal(withdrawal), + disabled: saving, + }, 'Loeschen')), + ])) + : [h('tr', { key: 'empty' }, h('td', { colSpan: 5 }, 'Noch keine Wallet-Auszahlungen hinterlegt.'))] + ), + ]), + ]), + ]), + panel('NC Wallet-Historie', 'Die letzten 10 NC-Wallet-Uploads mit allen aus dem Screenshot gelesenen Assets.', h('div', { className: 'mc-history-stack' }, + currentWalletSnapshots.length + ? currentWalletSnapshots.slice(0, 10).map((row) => h('article', { key: row.id, className: 'mc-history-card' }, [ + h('div', { key: 'head', className: 'mc-flex-split' }, [ + h('div', { key: 'time' }, [ + h('h3', { key: 'timestamp' }, fmtDate(row.measured_at)), + h('div', { key: 'source', className: 'mc-kicker' }, row.source || 'manual'), + ]), + ]), + h('div', { key: 'assets', className: 'mc-history-asset-grid' }, + Object.entries(row.balances_json || {}).map(([code, asset]) => { + const balance = asset && typeof asset === 'object' ? asset.balance : asset; + const priceAmount = asset && typeof asset === 'object' ? asset.price_amount : null; + const priceCurrency = asset && typeof asset === 'object' ? asset.price_currency : null; + return h('div', { key: code, className: 'mc-history-asset-card' }, [ + h('strong', { key: 'code' }, code), + h('span', { key: 'balance' }, `${fmtNumber(balance, 8)} ${code}`), + priceAmount !== null && priceAmount !== undefined + ? h('span', { key: 'price', className: 'mc-text' }, `1 ${code} = ${fmtNumber(priceAmount, 6)} ${priceCurrency || ''}`.trim()) + : h('span', { key: 'price-empty', className: 'mc-text' }, 'Kein Kurs erkannt'), + ]); + }) + ), + ])) + : h('div', { className: 'mc-empty' }, 'Noch keine NC-Wallet-Snapshots gespeichert.') + )), ]); } @@ -3080,6 +3182,18 @@ ]), ]), ], () => setPayoutModalOpen(false)) : null, + walletWithdrawalModalOpen ? renderModal('Aus NC Wallet auszahlen', [ + h('form', { key: 'form', className: 'mc-form', onSubmit: submitWalletWithdrawal }, [ + inputField('Auszahlungszeitpunkt', 'datetime-local', walletWithdrawalForm.withdrawal_at, (value) => setWalletWithdrawalForm({ ...walletWithdrawalForm, withdrawal_at: value })), + inputField('Coins', 'number', walletWithdrawalForm.coins_amount, (value) => setWalletWithdrawalForm({ ...walletWithdrawalForm, coins_amount: value }), '0.000001'), + selectField('Waehrung', walletWithdrawalForm.withdrawal_currency, [currentCoinCurrency].concat(selectableCurrencies.map((currency) => currency.code).filter((code) => code !== currentCoinCurrency)), (value) => setWalletWithdrawalForm({ ...walletWithdrawalForm, withdrawal_currency: value })), + textareaField('Notiz', walletWithdrawalForm.note, (value) => setWalletWithdrawalForm({ ...walletWithdrawalForm, note: value })), + h('div', { className: 'mc-inline-row' }, [ + h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setWalletWithdrawalModalOpen(false) }, 'Abbrechen'), + h('button', { type: 'submit', className: 'mc-button mc-button--secondary', disabled: saving }, saving ? 'Speichert …' : 'Wallet-Auszahlung speichern'), + ]), + ]), + ], () => setWalletWithdrawalModalOpen(false)) : null, minerOfferModalOpen ? renderModal('Basis-Miner-Angebot anlegen', [ h('form', { key: 'form', className: 'mc-form', onSubmit: submitMinerOffer }, [ inputField('Label', 'text', minerOfferForm.label, (value) => setMinerOfferForm({ ...minerOfferForm, label: value })), diff --git a/modules/mining-checker/design.json b/modules/mining-checker/design.json index a0335736..c5427d41 100644 --- a/modules/mining-checker/design.json +++ b/modules/mining-checker/design.json @@ -10,7 +10,7 @@ { "key": "overview", "label": "Übersicht" }, { "key": "upload", "label": "Upload" }, { "key": "measurements", "label": "Mining-History" }, - { "key": "wallet", "label": "Wallet" }, + { "key": "wallet", "label": "NC Wallet" }, { "key": "mining", "label": "Miner-Daten" }, { "key": "dashboards", "label": "Dashboards" } ] diff --git a/modules/mining-checker/sql/schema.mysql.sql b/modules/mining-checker/sql/schema.mysql.sql index 7f83b187..331020dd 100644 --- a/modules/mining-checker/sql/schema.mysql.sql +++ b/modules/mining-checker/sql/schema.mysql.sql @@ -126,6 +126,20 @@ CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots ( KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at) ); +CREATE TABLE IF NOT EXISTS miningcheck_wallet_withdrawals ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + withdrawal_at TIMESTAMP NOT NULL, + coins_amount DECIMAL(20,6) NOT NULL, + withdrawal_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_wallet_withdrawals_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_wallet_withdrawals_project_owner_at (project_key, owner_sub, withdrawal_at) +); + CREATE TABLE IF NOT EXISTS miningcheck_miner_offers ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, project_key VARCHAR(64) NOT NULL, diff --git a/modules/mining-checker/sql/schema.pgsql.sql b/modules/mining-checker/sql/schema.pgsql.sql index 232489dc..19cac557 100644 --- a/modules/mining-checker/sql/schema.pgsql.sql +++ b/modules/mining-checker/sql/schema.pgsql.sql @@ -137,6 +137,22 @@ CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots ( 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_wallet_withdrawals ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + withdrawal_at TIMESTAMP NOT NULL, + coins_amount NUMERIC(20,6) NOT NULL, + withdrawal_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_wallet_withdrawals_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE +); + +CREATE INDEX IF NOT EXISTS idx_miningcheck_wallet_withdrawals_project_owner_at + ON miningcheck_wallet_withdrawals(project_key, owner_sub, withdrawal_at); + CREATE TABLE IF NOT EXISTS miningcheck_miner_offers ( id BIGSERIAL PRIMARY KEY, project_key VARCHAR(64) NOT NULL, diff --git a/modules/mining-checker/sql/schema.sql b/modules/mining-checker/sql/schema.sql index 7f83b187..331020dd 100644 --- a/modules/mining-checker/sql/schema.sql +++ b/modules/mining-checker/sql/schema.sql @@ -126,6 +126,20 @@ CREATE TABLE IF NOT EXISTS miningcheck_wallet_snapshots ( KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at) ); +CREATE TABLE IF NOT EXISTS miningcheck_wallet_withdrawals ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + withdrawal_at TIMESTAMP NOT NULL, + coins_amount DECIMAL(20,6) NOT NULL, + withdrawal_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_wallet_withdrawals_project FOREIGN KEY (project_key) REFERENCES miningcheck_projects(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_wallet_withdrawals_project_owner_at (project_key, owner_sub, withdrawal_at) +); + CREATE TABLE IF NOT EXISTS miningcheck_miner_offers ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, project_key VARCHAR(64) NOT NULL, diff --git a/modules/mining-checker/src/Api/Router.php b/modules/mining-checker/src/Api/Router.php index 12598806..9dae9ffc 100644 --- a/modules/mining-checker/src/Api/Router.php +++ b/modules/mining-checker/src/Api/Router.php @@ -271,6 +271,20 @@ final class Router Http::json(['data' => $this->saveWalletSnapshot($projectKey, Http::input())], 201); } + if ($resource === 'wallet-withdrawals' && $method === 'GET') { + Http::json(['data' => $this->walletWithdrawals($projectKey)]); + } + + if ($resource === 'wallet-withdrawals' && $method === 'POST') { + $this->repository()->ensureProject($projectKey); + Http::json(['data' => $this->saveWalletWithdrawal($projectKey, Http::input())], 201); + } + + if (preg_match('~^wallet-withdrawals/(\d+)$~', $resource, $matches) && $method === 'DELETE') { + $this->deleteWalletWithdrawal($projectKey, (int) $matches[1]); + Http::json(['data' => ['deleted' => true]]); + } + if ($resource === 'miner-offers' && $method === 'GET') { Http::json(['data' => $this->minerOffers($projectKey)]); } @@ -343,6 +357,7 @@ final class Router 'cost_plans' => [], 'currencies' => [], 'payouts' => [], + 'wallet_withdrawals' => [], 'miner_offers' => [], 'purchased_miners' => [], 'measurement_rates' => [], @@ -1161,6 +1176,7 @@ final class Router $includeCostPlans = !array_key_exists('cost_plans', $options) || (bool) $options['cost_plans']; $includeCurrencies = !array_key_exists('currencies', $options) || (bool) $options['currencies']; $includePayouts = !array_key_exists('payouts', $options) || (bool) $options['payouts']; + $includeWalletWithdrawals = !array_key_exists('wallet_withdrawals', $options) || (bool) $options['wallet_withdrawals']; $includeMinerOffers = !array_key_exists('miner_offers', $options) || (bool) $options['miner_offers']; $includePurchasedMiners = !array_key_exists('purchased_miners', $options) || (bool) $options['purchased_miners']; @@ -1177,6 +1193,7 @@ final class Router 'module_theme_mode' => 'inherit', 'module_theme_accent' => 'teal', 'preferred_currencies' => $this->preferredCurrencies(), + 'wallet_withdrawals' => [], ]; if (!$this->isValidTimezone((string) ($base['display_timezone'] ?? ''))) { $base['display_timezone'] = nexus_display_timezone_name(); @@ -1192,6 +1209,7 @@ final class Router $base['currencies'] = $includeCurrencies ? $this->currencies() : []; $base['preferred_currencies'] = $this->preferredCurrencies($base['preferred_currencies'] ?? null); $base['payouts'] = $includePayouts ? $this->payouts($projectKey) : []; + $base['wallet_withdrawals'] = $includeWalletWithdrawals ? $this->walletWithdrawals($projectKey) : []; $base['miner_offers'] = $includeMinerOffers ? $this->minerOffers($projectKey) : []; $base['purchased_miners'] = $includePurchasedMiners ? $this->purchasedMiners($projectKey) : []; $base['measurement_rates'] = []; @@ -1378,8 +1396,9 @@ final class Router ], 'wallet' => [ 'cost_plans' => false, - 'currencies' => false, - 'payouts' => false, + 'currencies' => true, + 'payouts' => true, + 'wallet_withdrawals' => true, 'miner_offers' => false, 'purchased_miners' => false, ], @@ -1806,6 +1825,32 @@ final class Router return $this->repository()->saveWalletSnapshot($projectKey, $payload); } + private function walletWithdrawals(string $projectKey): array + { + if (!$this->repository()->tableExists('wallet_withdrawals')) { + return []; + } + + return $this->repository()->listWalletWithdrawals($projectKey); + } + + private function saveWalletWithdrawal(string $projectKey, array $input): array + { + $payload = [ + 'withdrawal_at' => $this->requiredDateTime($input['withdrawal_at'] ?? null, 'withdrawal_at', $this->projectTimezone($projectKey)), + 'coins_amount' => $this->requiredDecimal($input['coins_amount'] ?? null, 'coins_amount'), + 'withdrawal_currency' => $this->requiredCurrency($input['withdrawal_currency'] ?? 'DOGE', 'withdrawal_currency'), + 'note' => $this->optionalString($input['note'] ?? null, 1000), + ]; + + return $this->repository()->saveWalletWithdrawal($projectKey, $payload); + } + + private function deleteWalletWithdrawal(string $projectKey, int $withdrawalId): void + { + $this->repository()->deleteWalletWithdrawal($projectKey, $withdrawalId); + } + private function saveMinerOffer(string $projectKey, array $input): array { $paymentType = $this->enumValue($input['payment_type'] ?? 'fiat', ['fiat', 'crypto'], 'payment_type'); diff --git a/modules/mining-checker/src/Domain/AnalyticsService.php b/modules/mining-checker/src/Domain/AnalyticsService.php index 36525ce4..c3e5e259 100644 --- a/modules/mining-checker/src/Domain/AnalyticsService.php +++ b/modules/mining-checker/src/Domain/AnalyticsService.php @@ -31,6 +31,7 @@ final class AnalyticsService $baselineAt = (string) ($settings['baseline_measured_at'] ?? ''); $costPlans = is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : []; $payouts = is_array($settings['payouts'] ?? null) ? $settings['payouts'] : []; + $walletWithdrawals = is_array($settings['wallet_withdrawals'] ?? null) ? $settings['wallet_withdrawals'] : []; $purchasedMiners = is_array($settings['purchased_miners'] ?? null) ? $settings['purchased_miners'] : []; $preferredPriceCurrencies = array_values(array_unique(array_filter([ strtoupper(trim((string) ($settings['report_currency'] ?? ''))), @@ -368,7 +369,7 @@ final class AnalyticsService 'reinvest' ) : null; - $walletBalances = $this->walletBalances($payouts, $purchasedMiners, $latestMeasuredTs); + $walletBalances = $this->walletBalances($payouts, $walletWithdrawals, $purchasedMiners, $latestMeasuredTs); $latestWalletSnapshot = $this->latestWalletSnapshot(is_array($options['wallet_snapshots'] ?? null) ? $options['wallet_snapshots'] : []); if (is_array($latestWalletSnapshot)) { $snapshotBalances = $this->walletSnapshotBalances($latestWalletSnapshot); @@ -1297,7 +1298,7 @@ final class AnalyticsService return $endIndex - $startIndex + 1; } - private function walletBalances(array $payouts, array $purchasedMiners, int $measurementTs): array + private function walletBalances(array $payouts, array $walletWithdrawals, array $purchasedMiners, int $measurementTs): array { $balances = []; @@ -1334,6 +1335,21 @@ final class AnalyticsService $balances[$currency] = ($balances[$currency] ?? 0.0) - $amount; } + foreach ($walletWithdrawals as $withdrawal) { + $withdrawalTs = $this->utcTimestamp((string) ($withdrawal['withdrawal_at'] ?? '')); + if ($measurementTs > 0 && $withdrawalTs > $measurementTs) { + continue; + } + + $currency = strtoupper(trim((string) ($withdrawal['withdrawal_currency'] ?? ''))); + $amount = is_numeric($withdrawal['coins_amount'] ?? null) ? (float) $withdrawal['coins_amount'] : null; + if ($currency === '' || $amount === null) { + continue; + } + + $balances[$currency] = ($balances[$currency] ?? 0.0) - $amount; + } + ksort($balances); return array_map(fn (float $value): float => round($value, 8), $balances); } diff --git a/modules/mining-checker/src/Infrastructure/MiningRepository.php b/modules/mining-checker/src/Infrastructure/MiningRepository.php index 02507a25..e081e166 100644 --- a/modules/mining-checker/src/Infrastructure/MiningRepository.php +++ b/modules/mining-checker/src/Infrastructure/MiningRepository.php @@ -459,6 +459,76 @@ final class MiningRepository ]); } + public function listWalletWithdrawals(string $projectKey): array + { + $stmt = $this->pdo->prepare( + 'SELECT * FROM ' . $this->table('wallet_withdrawals') . ' WHERE project_key = :project_key AND owner_sub = :owner_sub ORDER BY withdrawal_at ASC, id ASC' + ); + $stmt->execute(['project_key' => $projectKey, 'owner_sub' => $this->ownerSub]); + return $this->normalizeRows($stmt->fetchAll() ?: []); + } + + public function saveWalletWithdrawal(string $projectKey, array $payload): array + { + if ($this->driver === 'pgsql') { + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('wallet_withdrawals') . ' ( + project_key, owner_sub, withdrawal_at, coins_amount, withdrawal_currency, note + ) VALUES ( + :project_key, :owner_sub, :withdrawal_at, :coins_amount, :withdrawal_currency, :note + ) + RETURNING *' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'withdrawal_at' => $payload['withdrawal_at'], + 'coins_amount' => $payload['coins_amount'], + 'withdrawal_currency' => $payload['withdrawal_currency'], + 'note' => $payload['note'], + ]); + return $this->normalizeRow($stmt->fetch() ?: []); + } + + $stmt = $this->pdo->prepare( + 'INSERT INTO ' . $this->table('wallet_withdrawals') . ' ( + project_key, owner_sub, withdrawal_at, coins_amount, withdrawal_currency, note + ) VALUES ( + :project_key, :owner_sub, :withdrawal_at, :coins_amount, :withdrawal_currency, :note + )' + ); + $stmt->execute([ + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'withdrawal_at' => $payload['withdrawal_at'], + 'coins_amount' => $payload['coins_amount'], + 'withdrawal_currency' => $payload['withdrawal_currency'], + 'note' => $payload['note'], + ]); + + $id = (int) $this->pdo->lastInsertId(); + $fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('wallet_withdrawals') . ' WHERE id = :id LIMIT 1'); + $fetch->execute(['id' => $id]); + return $this->normalizeRow($fetch->fetch() ?: []); + } + + public function deleteWalletWithdrawal(string $projectKey, int $withdrawalId): void + { + if ($withdrawalId <= 0) { + return; + } + + $stmt = $this->pdo->prepare( + 'DELETE FROM ' . $this->table('wallet_withdrawals') . ' + WHERE id = :id AND project_key = :project_key AND owner_sub = :owner_sub' + ); + $stmt->execute([ + 'id' => $withdrawalId, + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + ]); + } + public function listWalletSnapshots(string $projectKey, int $limit = 100): array { $stmt = $this->pdo->prepare( diff --git a/modules/mining-checker/src/Infrastructure/SchemaManager.php b/modules/mining-checker/src/Infrastructure/SchemaManager.php index c4c40256..3ba782e5 100644 --- a/modules/mining-checker/src/Infrastructure/SchemaManager.php +++ b/modules/mining-checker/src/Infrastructure/SchemaManager.php @@ -205,6 +205,10 @@ final class SchemaManager $this->ensureWalletSnapshotsTable(); $applied[] = 'wallet_snapshots_table'; } + if (!$this->tableExists($this->prefix . 'wallet_withdrawals')) { + $this->ensureWalletWithdrawalsTable(); + $applied[] = 'wallet_withdrawals_table'; + } if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) { $this->ensureFxRatesTable(); $applied[] = 'fx_rates_table'; @@ -324,6 +328,10 @@ final class SchemaManager $this->ensureWalletSnapshotsTable(); $applied[] = 'wallet_snapshots_table'; } + if (!$this->tableExists($this->prefix . 'wallet_withdrawals')) { + $this->ensureWalletWithdrawalsTable(); + $applied[] = 'wallet_withdrawals_table'; + } if (!$this->tableExists($this->prefix . 'miner_offers')) { $this->upgradeMinerOffersTable(); @@ -679,6 +687,9 @@ final class SchemaManager if (!$this->tableExists($this->prefix . 'wallet_snapshots')) { $upgrades[] = 'wallet_snapshots_table'; } + if (!$this->tableExists($this->prefix . 'wallet_withdrawals')) { + $upgrades[] = 'wallet_withdrawals_table'; + } if (!$this->tableExists($this->prefix . 'miner_offers')) { $upgrades[] = 'miner_offers_table'; } @@ -978,6 +989,49 @@ final class SchemaManager $this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer Wallet-Snapshots fehlgeschlagen.'); } + public function ensureWalletWithdrawalsTable(): void + { + if ($this->tableExists($this->prefix . 'wallet_withdrawals')) { + return; + } + + $table = $this->prefix . 'wallet_withdrawals'; + $projectTable = $this->prefix . 'projects'; + $statements = $this->driver === 'pgsql' + ? [ + 'CREATE TABLE IF NOT EXISTS ' . $table . ' ( + id BIGSERIAL PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + withdrawal_at TIMESTAMP NOT NULL, + coins_amount NUMERIC(20,6) NOT NULL, + withdrawal_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_wallet_withdrawals_project FOREIGN KEY (project_key) REFERENCES ' . $projectTable . '(project_key) ON DELETE CASCADE + )', + 'CREATE INDEX IF NOT EXISTS idx_miningcheck_wallet_withdrawals_project_owner_at ON ' . $table . ' (project_key, owner_sub, withdrawal_at)', + ] + : [ + 'CREATE TABLE IF NOT EXISTS `' . $table . '` ( + id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT PRIMARY KEY, + project_key VARCHAR(64) NOT NULL, + owner_sub VARCHAR(128) NOT NULL, + withdrawal_at TIMESTAMP NOT NULL, + coins_amount DECIMAL(20,6) NOT NULL, + withdrawal_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_wallet_withdrawals_project FOREIGN KEY (project_key) REFERENCES `' . $projectTable . '`(project_key) ON DELETE CASCADE, + KEY idx_miningcheck_wallet_withdrawals_project_owner_at (project_key, owner_sub, withdrawal_at) + )', + ]; + + $this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer Wallet-Auszahlungen fehlgeschlagen.'); + } + public function ensureMinerTables(): void { if (!$this->tableExists($this->prefix . 'miner_offers')) { @@ -1451,6 +1505,7 @@ final class SchemaManager $this->prefix . 'measurement_rates', $this->prefix . 'payouts', $this->prefix . 'wallet_snapshots', + $this->prefix . 'wallet_withdrawals', $this->prefix . 'miner_offers', $this->prefix . 'purchased_miners', ]; @@ -1471,6 +1526,7 @@ final class SchemaManager $this->prefix . 'measurement_rates', $this->prefix . 'payouts', $this->prefix . 'wallet_snapshots', + $this->prefix . 'wallet_withdrawals', $this->prefix . 'miner_offers', $this->prefix . 'targets', $this->prefix . 'dashboard_definitions',