diff --git a/custom/apps/mining-checker/assets/js/app.js b/custom/apps/mining-checker/assets/js/app.js index 099010b2..ad5456c8 100644 --- a/custom/apps/mining-checker/assets/js/app.js +++ b/custom/apps/mining-checker/assets/js/app.js @@ -1016,7 +1016,21 @@ ? `Verwendet den letzten Upload von ${fmtDate(latest.measured_at)} mit ${fmtNumber(fullTransferCoins, 6)} ${currentMiningCurrency}.` : `Verwendet den letzten Upload von ${fmtDate(latest.measured_at)} mit ${fmtNumber(fullTransferCoins, 6)} ${currentMiningCurrency}. Backend erlaubt den Kompletttransfer nur, wenn dieser Upload hoechstens 3 Minuten alt ist.`; const latestWalletSnapshot = currentWalletSnapshots.length ? currentWalletSnapshots[0] : null; - const currentWalletMiningBalance = walletAssetBalance(latestWalletSnapshot, currentMiningCurrency); + const currentWalletPrimaryCurrency = (() => { + const snapshotCurrency = String((latestWalletSnapshot && latestWalletSnapshot.wallet_currency) || '').toUpperCase(); + if (snapshotCurrency) { + return snapshotCurrency; + } + const latestWithdrawal = currentWalletWithdrawals.length ? currentWalletWithdrawals[currentWalletWithdrawals.length - 1] : null; + const latestWalletTransfer = currentPayouts.length ? currentPayouts[currentPayouts.length - 1] : null; + const ledgerCurrency = String( + (latestWithdrawal && latestWithdrawal.withdrawal_currency) + || (latestWalletTransfer && latestWalletTransfer.payout_currency) + || '' + ).toUpperCase(); + return ledgerCurrency || currentMiningCurrency || currentSettings.crypto_currency || 'DOGE'; + })(); + const currentWalletMiningBalance = walletPurchasingPower(latestWalletSnapshot, currentMiningCurrency); const preferredCurrencyCodes = Array.isArray(currentSettings.preferred_currencies) ? currentSettings.preferred_currencies.map((code) => String(code || '').toUpperCase()).filter(Boolean) : []; @@ -1053,7 +1067,7 @@ return false; } - if (selectedOfferType === 'crypto' && !isOfferAvailableForWallet(offer, currentMiningCurrency, currentWalletMiningBalance)) { + if (selectedOfferType === 'crypto' && !isOfferAvailableForWallet(offer, latestWalletSnapshot)) { return false; } @@ -1075,7 +1089,7 @@ return true; }); - const scenarioMinerOffers = filteredMinerOffers.filter((offer) => isOfferAvailableForWallet(offer, currentMiningCurrency, currentWalletMiningBalance)); + const scenarioMinerOffers = filteredMinerOffers.filter((offer) => isOfferAvailableForWallet(offer, latestWalletSnapshot)); const speedUnits = ['kH/s', 'MH/s']; const fiatCurrencies = currencies.filter((currency) => !currency.is_crypto); const cryptoCurrencies = currencies.filter((currency) => !!currency.is_crypto); @@ -1342,7 +1356,56 @@ return Number.isFinite(balance) ? balance : null; } - function isOfferAvailableForWallet(offer, miningCurrency, walletBalance) { + function walletPurchasingPower(snapshot, targetCurrency) { + const target = String(targetCurrency || '').toUpperCase(); + if (!snapshot || !target) { + return null; + } + + const directBalance = walletAssetBalance(snapshot, target); + if (Number.isFinite(directBalance)) { + return directBalance; + } + + const totalValueAmount = Number(snapshot.total_value_amount); + const totalValueCurrency = String(snapshot.total_value_currency || '').toUpperCase(); + if (Number.isFinite(totalValueAmount) && totalValueCurrency) { + const convertedTotal = convertCurrencyValue(totalValueAmount, totalValueCurrency, target); + if (Number.isFinite(convertedTotal)) { + return convertedTotal; + } + } + + const assets = snapshot.balances_json && typeof snapshot.balances_json === 'object' ? snapshot.balances_json : {}; + let total = 0; + let matched = false; + for (const [assetCode, assetValue] of Object.entries(assets)) { + const normalizedCode = String(assetCode || '').toUpperCase(); + const balance = Number(assetValue && typeof assetValue === 'object' ? assetValue.balance : assetValue); + const priceAmount = Number(assetValue && typeof assetValue === 'object' ? assetValue.price_amount : null); + const priceCurrency = String(assetValue && typeof assetValue === 'object' ? assetValue.price_currency : '').toUpperCase(); + if (!normalizedCode || !Number.isFinite(balance) || balance <= 0) { + continue; + } + if (normalizedCode === target) { + total += balance; + matched = true; + continue; + } + if (Number.isFinite(priceAmount) && priceAmount > 0 && priceCurrency) { + const assetValueInPriceCurrency = balance * priceAmount; + const converted = convertCurrencyValue(assetValueInPriceCurrency, priceCurrency, target); + if (Number.isFinite(converted)) { + total += converted; + matched = true; + } + } + } + + return matched ? total : null; + } + + function isOfferAvailableForWallet(offer, walletSnapshot) { if (!offer || !offer.is_active) { return false; } @@ -1352,11 +1415,10 @@ } const currency = String(offer.effective_price_currency || offer.price_currency || '').toUpperCase(); - const expectedCurrency = String(miningCurrency || '').toUpperCase(); const price = Number(offer.effective_price_amount); - const balance = Number(walletBalance); + const balance = Number(walletPurchasingPower(walletSnapshot, currency)); - if (!currency || !expectedCurrency || currency !== expectedCurrency || !Number.isFinite(price) || !Number.isFinite(balance)) { + if (!currency || !Number.isFinite(price) || !Number.isFinite(balance)) { return true; } @@ -1676,11 +1738,15 @@ const preview = normalizeOcrPreview(ocrPreview); const raw = fromPreview ? { ...preview.suggested, + coin_currency: currentMiningCurrency, image_path: preview.image_path, ocr_raw_text: preview.raw_text, ocr_confidence: preview.confidence, ocr_flags: preview.flags, - } : measurementForm; + } : { + ...measurementForm, + coin_currency: currentMiningCurrency, + }; setSaving(true); setError(''); @@ -1819,7 +1885,8 @@ body.append('image', nextForm.image); body.append('date_context', nextForm.date_context); body.append('ocr_hint_text', nextForm.ocr_hint_text); - body.append('wallet_currency_hint', currentSettings.crypto_currency || 'DOGE'); + body.append('wallet_currency_hint', currentWalletPrimaryCurrency); + body.append('mining_currency_hint', currentMiningCurrency); const data = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/ocr-preview`, { method: 'POST', body, @@ -2037,7 +2104,7 @@ applySavedPayout(savedPayout); setWalletTransferRows((previous) => previous.filter((row) => String(row.id) !== String(savedPayout.id)).concat(savedPayout).sort((left, right) => String(left?.payout_at || '').localeCompare(String(right?.payout_at || '')))); invalidateProjectBootstrapCache(projectKey); - setPayoutForm({ payout_at: '', coins_amount: '', payout_currency: currentSettings.crypto_currency || 'DOGE', note: '' }); + setPayoutForm({ payout_at: '', coins_amount: '', payout_currency: currentMiningCurrency, note: '' }); setPayoutModalOpen(false); await reloadBootstrapAfterMutation('Wallet-Transfer gespeichert.'); } catch (err) { @@ -2131,7 +2198,7 @@ setWalletWithdrawalForm({ withdrawal_at: '', coins_amount: '', - withdrawal_currency: currentSettings.crypto_currency || 'DOGE', + withdrawal_currency: currentWalletPrimaryCurrency, note: '', }); setWalletWithdrawalModalOpen(false); @@ -2480,7 +2547,7 @@ isWalletPreview ? h('div', { key: 'wallet-form', className: 'mc-two-col' }, [ displayField('Erkannter Typ', 'Wallet-Snapshot'), - displayField('Mining-Waehrung im Wallet', `${fmtNumber(preview.suggested_wallet.wallet_balance, 8)} ${preview.suggested_wallet.wallet_currency || currentSettings.crypto_currency || 'DOGE'}`), + displayField('Wallet-Bestand', `${fmtNumber(preview.suggested_wallet.wallet_balance, 8)} ${preview.suggested_wallet.wallet_currency || currentWalletPrimaryCurrency}`), displayField('Gesamtwert', preview.suggested_wallet.total_value_amount !== '' && preview.suggested_wallet.total_value_amount !== null ? `${fmtNumber(preview.suggested_wallet.total_value_amount, 4)} ${preview.suggested_wallet.total_value_currency || ''}`.trim() : 'n/a'), @@ -2629,6 +2696,7 @@ const breakEvenReached = breakEvenRemainingAmount !== null && breakEvenRemainingAmount <= 0; const breakEvenEta = latest && latest.break_even_eta_at ? fmtDate(latest.break_even_eta_at) : null; const walletBalanceCurrentAsset = payload?.summary?.payouts?.wallet_balance_current_asset; + const walletBalanceCurrentAssetDirect = payload?.summary?.payouts?.wallet_balance_current_asset_direct; const holdingsCurrentAsset = payload?.summary?.payouts?.holdings_current_asset; return h('div', { className: 'mc-stack' }, [ @@ -2660,7 +2728,7 @@ label: `Theoretischer Bestand ${currentCoinCurrency}`, value: payload?.summary?.payouts ? fmtNumber(holdingsCurrentAsset, 6) : 'n/a', sub: payload?.summary?.payouts - ? `Wallet ${fmtNumber(walletBalanceCurrentAsset, 6)} ${currentCoinCurrency} · Miner ${fmtNumber(latest?.coins_total_visible ?? latest?.coins_total, 6)} ${currentCoinCurrency}` + ? `Wallet-Gegenwert ${fmtNumber(walletBalanceCurrentAsset, 6)} ${currentCoinCurrency}${currentWalletPrimaryCurrency !== currentCoinCurrency ? ` · direkt ${fmtNumber(walletBalanceCurrentAssetDirect, 6)} ${currentCoinCurrency}` : ''} · Miner ${fmtNumber(latest?.coins_total_visible ?? latest?.coins_total, 6)} ${currentCoinCurrency}` : '', }), h(StatCard, { @@ -2944,7 +3012,7 @@ setWalletWithdrawalForm((previous) => ({ withdrawal_at: previous.withdrawal_at || nowDateTimeLocalValue(), coins_amount: previous.coins_amount || '', - withdrawal_currency: currentMiningCurrency, + withdrawal_currency: currentWalletPrimaryCurrency, note: previous.note || '', })); setWalletWithdrawalModalOpen(true); @@ -3117,7 +3185,7 @@ ]), panel('Miner-Angebote', selectedOfferType === 'crypto' ? (currentWalletMiningBalance !== null - ? `Crypto-Angebote sind aktiv. Angezeigt werden nur Angebote, die dein NC-Wallet aktuell tragen kann (${fmtNumber(currentWalletMiningBalance, 8)} ${currentMiningCurrency}).` + ? `Crypto-Angebote sind aktiv. Angezeigt werden nur Angebote, die dein NC-Wallet aktuell tragen kann (${fmtNumber(currentWalletMiningBalance, 8)} ${currentMiningCurrency} Gegenwert).` : 'Crypto-Angebote sind aktiv. Solange kein Wallet-Bestand erkannt wird, bleiben die Krypto-Angebote sichtbar.') : 'Fiat-Angebote sind aktiv. Die Berechnung startet erst, wenn du eine Geschwindigkeit eingibst.', [ @@ -3224,7 +3292,7 @@ ? h('div', { key: 'rec-ref', className: 'mc-kicker' }, `Basis ${fmtNumber(offer.base_price_amount, 6)} ${offer.base_price_currency}`) : null, offer.payment_type ? h('div', { key: 'paytype', className: 'mc-kicker' }, offer.payment_type === 'crypto' ? `Zahlung in Krypto (${currentSettings.crypto_currency || 'DOGE'})` : `Zahlung in FIAT (${offer.base_price_currency || 'EUR'})`) : null, - h('div', { key: 'availability', className: 'mc-kicker' }, isOfferAvailableForWallet(offer, currentMiningCurrency, currentWalletMiningBalance) ? 'Im NC Wallet verfuegbar' : 'Nicht genug NC Wallet Bestand'), + h('div', { key: 'availability', className: 'mc-kicker' }, isOfferAvailableForWallet(offer, latestWalletSnapshot) ? 'Im NC Wallet verfuegbar' : 'Nicht genug NC Wallet Bestand'), h('div', { key: 'renew', className: 'mc-kicker' }, offer.auto_renew ? 'Automatische Verlängerung' : 'Laeuft aus'), ]), h('td', { key: 'action' }, [ @@ -3272,7 +3340,7 @@ }); setPurchaseMinerModalOpen(true); }, - disabled: !isOfferAvailableForWallet(offer, currentMiningCurrency, currentWalletMiningBalance), + disabled: !isOfferAvailableForWallet(offer, latestWalletSnapshot), }, 'Mieten'), ]), ])) @@ -3434,7 +3502,7 @@ 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, [currentMiningCurrency].concat(selectableCurrencies.map((currency) => currency.code).filter((code) => code !== currentMiningCurrency)), (value) => setWalletWithdrawalForm({ ...walletWithdrawalForm, withdrawal_currency: value })), + selectField('Waehrung', walletWithdrawalForm.withdrawal_currency, [currentWalletPrimaryCurrency].concat(selectableCurrencies.map((currency) => currency.code).filter((code) => code !== currentWalletPrimaryCurrency)), (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'), @@ -3656,7 +3724,7 @@ 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, [currentMiningCurrency].concat(selectableCurrencies.map((currency) => currency.code).filter((code) => code !== currentMiningCurrency)), (value) => setWalletWithdrawalForm({ ...walletWithdrawalForm, withdrawal_currency: value })), + selectField('Waehrung', walletWithdrawalForm.withdrawal_currency, [currentWalletPrimaryCurrency].concat(selectableCurrencies.map((currency) => currency.code).filter((code) => code !== currentWalletPrimaryCurrency)), (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'), diff --git a/custom/apps/mining-checker/src/Api/Router.php b/custom/apps/mining-checker/src/Api/Router.php index efb6f136..3cde5bda 100644 --- a/custom/apps/mining-checker/src/Api/Router.php +++ b/custom/apps/mining-checker/src/Api/Router.php @@ -1262,8 +1262,10 @@ final class Router $this->assertCurrencyType($settings['report_currency'], false, 'report_currency'); $this->assertCurrencyType($settings['crypto_currency'], true, 'crypto_currency'); + $currencyChange = $this->prepareMiningCurrencyChange($projectKey, $existingSettings, $settings); $this->repository()->saveSettings($projectKey, $settings); + $this->applyPreparedMiningCurrencyChange($projectKey, $currencyChange); $this->syncFxRatesPreferredCurrencies($settings['preferred_currencies']); return $this->settings($projectKey); } @@ -1973,12 +1975,17 @@ final class Router $balances = []; } + $walletCurrency = $this->optionalCurrency($input['wallet_currency'] ?? null) + ?? $this->inferWalletCurrencyFromBalances($balances) + ?? $this->latestWalletCurrency($projectKey) + ?? 'DOGE'; + $payload = [ 'measured_at' => $this->requiredDateTime($input['measured_at'] ?? null, 'measured_at', $this->projectTimezone($projectKey)), 'total_value_amount' => $this->optionalDecimal($input['total_value_amount'] ?? null), 'total_value_currency' => $this->optionalCurrency($input['total_value_currency'] ?? null), 'wallet_balance' => $this->optionalDecimal($input['wallet_balance'] ?? null), - 'wallet_currency' => $this->requiredCurrency($input['wallet_currency'] ?? ($this->settings($projectKey)['crypto_currency'] ?? 'DOGE'), 'wallet_currency'), + 'wallet_currency' => $this->requiredCurrency($walletCurrency, 'wallet_currency'), 'balances_json' => $balances, 'note' => $this->optionalString($input['note'] ?? null, 1000), 'source' => $this->enumValue($input['source'] ?? 'manual', ['manual', 'image_ocr', 'seed_import'], 'source'), @@ -2002,10 +2009,14 @@ final class Router private function saveWalletWithdrawal(string $projectKey, array $input): array { + $withdrawalCurrency = $this->optionalCurrency($input['withdrawal_currency'] ?? null) + ?? $this->latestWalletCurrency($projectKey) + ?? 'DOGE'; + $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'), + 'withdrawal_currency' => $this->requiredCurrency($withdrawalCurrency, 'withdrawal_currency'), 'note' => $this->optionalString($input['note'] ?? null, 1000), ]; @@ -2765,6 +2776,177 @@ final class Router return $this->requiredCurrency($settings['crypto_currency'] ?? 'DOGE', 'crypto_currency'); } + private function prepareMiningCurrencyChange(string $projectKey, array $existingSettings, array &$nextSettings): ?array + { + $previousCurrency = $this->requiredCurrency($existingSettings['crypto_currency'] ?? ($nextSettings['crypto_currency'] ?? 'DOGE'), 'crypto_currency'); + $nextCurrency = $this->requiredCurrency($nextSettings['crypto_currency'] ?? 'DOGE', 'crypto_currency'); + if ($previousCurrency === $nextCurrency) { + return null; + } + + $latestMeasurement = $this->repository()->listRecentMeasurements($projectKey, 1)[0] ?? null; + $fetchId = null; + try { + $fresh = $this->fx()->refreshLatestRates(null, 'USD'); + $fetchId = is_numeric($fresh['fetch_id'] ?? null) ? (int) $fresh['fetch_id'] : null; + } catch (\Throwable) { + $fetchId = null; + } + + $baselineAmount = $this->optionalDecimal($nextSettings['baseline_coins_total'] ?? null); + if ($baselineAmount !== null && $baselineAmount > 0) { + $convertedBaseline = $this->convertMiningAssetAmount( + $baselineAmount, + $previousCurrency, + $nextCurrency, + (string) ($nextSettings['baseline_measured_at'] ?? $existingSettings['baseline_measured_at'] ?? ''), + $fetchId + ); + if ($convertedBaseline === null) { + throw new ApiException('Baseline konnte nicht in die neue Mining-Waehrung umgerechnet werden.', 422, [ + 'from_currency' => $previousCurrency, + 'to_currency' => $nextCurrency, + ]); + } + $nextSettings['baseline_coins_total'] = $convertedBaseline; + } + + return [ + 'previous_currency' => $previousCurrency, + 'next_currency' => $nextCurrency, + 'latest_measurement' => is_array($latestMeasurement) ? $latestMeasurement : null, + 'fetch_id' => $fetchId, + ]; + } + + private function applyPreparedMiningCurrencyChange(string $projectKey, ?array $change): void + { + if (!is_array($change)) { + return; + } + + $latestMeasurement = is_array($change['latest_measurement'] ?? null) ? $change['latest_measurement'] : null; + if (!is_array($latestMeasurement)) { + return; + } + + $sourceCurrency = $this->requiredCurrency($latestMeasurement['coin_currency'] ?? ($change['previous_currency'] ?? null), 'coin_currency'); + $targetCurrency = $this->requiredCurrency($change['next_currency'] ?? null, 'crypto_currency'); + if ($sourceCurrency === $targetCurrency) { + return; + } + + $visibleCoins = $this->optionalDecimal($latestMeasurement['coins_total'] ?? null) ?? 0.0; + $convertedCoins = $this->convertMiningAssetAmount( + $visibleCoins, + $sourceCurrency, + $targetCurrency, + (string) ($latestMeasurement['measured_at'] ?? $this->currentTimestamp()), + is_numeric($change['fetch_id'] ?? null) ? (int) $change['fetch_id'] : null + ); + if ($convertedCoins === null) { + throw new ApiException('Aktueller Miner-Bestand konnte nicht in die neue Mining-Waehrung umgerechnet werden.', 422, [ + 'from_currency' => $sourceCurrency, + 'to_currency' => $targetCurrency, + ]); + } + + $priceCurrency = $this->optionalCurrency($latestMeasurement['price_currency'] ?? null) ?? 'USD'; + $convertedPricePerCoin = $this->convertMiningAssetAmount( + 1.0, + $targetCurrency, + $priceCurrency, + $this->currentTimestamp(), + is_numeric($change['fetch_id'] ?? null) ? (int) $change['fetch_id'] : null + ); + + $this->repository()->createMeasurement($projectKey, [ + 'measured_at' => $this->currentTimestamp(), + 'coins_total' => $convertedCoins, + 'coin_currency' => $targetCurrency, + 'price_per_coin' => $convertedPricePerCoin, + 'price_currency' => $priceCurrency, + 'fx_fetch_id' => is_numeric($change['fetch_id'] ?? null) ? (int) $change['fetch_id'] : null, + 'note' => sprintf( + 'Mining-Waehrung umgestellt: %s -> %s. Vorhandener Miner-Bestand wurde automatisch umgerechnet.', + $sourceCurrency, + $targetCurrency + ), + 'source' => 'manual', + 'image_path' => null, + 'ocr_raw_text' => null, + 'ocr_confidence' => null, + 'ocr_flags' => ['currency_change'], + ]); + } + + private function convertMiningAssetAmount(float $amount, string $fromCurrency, string $toCurrency, string $at = '', ?int $fetchId = null): ?float + { + if ($amount === 0.0) { + return 0.0; + } + + $from = $this->requiredCurrency($fromCurrency, 'from_currency'); + $to = $this->requiredCurrency($toCurrency, 'to_currency'); + if ($from === $to) { + return $amount; + } + + $converted = $this->fx()->convertAt( + $amount, + $from, + $to, + $at !== '' ? $at : null, + null, + $fetchId + ); + + return is_numeric($converted) ? (float) $converted : null; + } + + private function latestWalletCurrency(string $projectKey): ?string + { + $latestSnapshot = $this->repository()->listWalletSnapshots($projectKey, 1)[0] ?? null; + $snapshotCurrency = $this->optionalCurrency(is_array($latestSnapshot) ? ($latestSnapshot['wallet_currency'] ?? null) : null); + if ($snapshotCurrency !== null) { + return $snapshotCurrency; + } + + if ($this->repository()->tableExists('wallet_withdrawals')) { + $withdrawals = $this->repository()->listWalletWithdrawals($projectKey); + if ($withdrawals !== []) { + $withdrawalCurrency = $this->optionalCurrency($withdrawals[array_key_last($withdrawals)]['withdrawal_currency'] ?? null); + if ($withdrawalCurrency !== null) { + return $withdrawalCurrency; + } + } + } + + $payouts = $this->repository()->listPayouts($projectKey); + if ($payouts !== []) { + return $this->optionalCurrency($payouts[array_key_last($payouts)]['payout_currency'] ?? null); + } + + return null; + } + + private function inferWalletCurrencyFromBalances(array $balances): ?string + { + foreach ($balances as $code => $asset) { + $currency = $this->optionalCurrency($code); + if ($currency === null) { + continue; + } + + $balance = is_array($asset) ? ($asset['balance'] ?? null) : $asset; + if (is_numeric($balance)) { + return $currency; + } + } + + return null; + } + private function ensureMeasurementFxReferences(string $projectKey, array $rows, ?array $settings = null): array { $maxAgeHours = self::FX_FETCH_MAX_AGE_HOURS; diff --git a/custom/apps/mining-checker/src/Domain/AnalyticsService.php b/custom/apps/mining-checker/src/Domain/AnalyticsService.php index 47fc23e7..87afe45d 100644 --- a/custom/apps/mining-checker/src/Domain/AnalyticsService.php +++ b/custom/apps/mining-checker/src/Domain/AnalyticsService.php @@ -429,8 +429,15 @@ final class AnalyticsService $postMeasurementPayoutAmount = $this->postMeasurementPayoutAmount($payouts, $latestMeasuredTs, $latestAsset); $visibleCoinsAtMeasurement = (float) ($latest['coins_total_visible'] ?? $latest['coins_total'] ?? 0.0); $adjustedVisibleCoins = max(0.0, $visibleCoinsAtMeasurement - $postMeasurementPayoutAmount); - $walletBalanceCurrentAsset = (float) ($walletBalances[$latestAsset] ?? 0.0); - $holdingsCurrentAsset = $walletBalanceCurrentAsset + $adjustedVisibleCoins; + $walletBalanceCurrentAssetDirect = (float) ($walletBalances[$latestAsset] ?? 0.0); + $walletBalanceCurrentAssetEquivalent = $latestAsset !== '' + ? ( + $useWalletSnapshot + ? $this->walletSnapshotValue($latestWalletSnapshot, $latestAsset, $latest) + : $this->walletBalanceValue($walletBalances, $latestAsset, $latest) + ) + : null; + $holdingsCurrentAsset = ($walletBalanceCurrentAssetEquivalent !== null ? $walletBalanceCurrentAssetEquivalent : 0.0) + $adjustedVisibleCoins; $walletValue = $latestCurrency !== '' ? ( $useWalletSnapshot @@ -478,7 +485,8 @@ final class AnalyticsService 'invested_capital' => $this->roundOrNull($cashInvestedCapital, 8), 'cash_invested_capital' => $this->roundOrNull($cashInvestedCapital, 8), 'reinvested_capital' => $this->roundOrNull($reinvestedCapital, 8), - 'wallet_balance_current_asset' => $this->roundOrNull($walletBalanceCurrentAsset, 6), + 'wallet_balance_current_asset' => $this->roundOrNull($walletBalanceCurrentAssetEquivalent, 6), + 'wallet_balance_current_asset_direct' => $this->roundOrNull($walletBalanceCurrentAssetDirect, 6), 'holdings_current_asset' => $this->roundOrNull($holdingsCurrentAsset, 6), 'wallet_value' => $this->roundOrNull($walletValue, 8), 'wallet_snapshot_measured_at' => $useWalletSnapshot ? (string) ($latestWalletSnapshot['measured_at'] ?? '') : null, @@ -533,7 +541,8 @@ final class AnalyticsService 'current_visible_coins' => $this->roundOrNull($adjustedVisibleCoins, 6), 'current_effective_coins' => $this->roundOrNull((float) ($latest['coins_total_effective'] ?? $latest['coins_total']), 6), 'wallet_balances' => $walletBalances, - 'wallet_balance_current_asset' => $this->roundOrNull($walletBalanceCurrentAsset, 6), + 'wallet_balance_current_asset' => $this->roundOrNull($walletBalanceCurrentAssetEquivalent, 6), + 'wallet_balance_current_asset_direct' => $this->roundOrNull($walletBalanceCurrentAssetDirect, 6), 'holdings_current_asset' => $this->roundOrNull($holdingsCurrentAsset, 6), ], 'current_hashrate_mh' => $this->roundOrNull($currentHashrateMh, 4), diff --git a/custom/apps/mining-checker/src/Domain/OcrService.php b/custom/apps/mining-checker/src/Domain/OcrService.php index 256e1ca4..b0a168dd 100644 --- a/custom/apps/mining-checker/src/Domain/OcrService.php +++ b/custom/apps/mining-checker/src/Domain/OcrService.php @@ -49,7 +49,8 @@ final class OcrService $parsed = $this->parseText( $rawText, (string) ($input['date_context'] ?? date('Y-m-d')), - strtoupper(trim((string) ($input['wallet_currency_hint'] ?? ''))) + strtoupper(trim((string) ($input['wallet_currency_hint'] ?? ''))), + strtoupper(trim((string) ($input['mining_currency_hint'] ?? ''))) ); $parsed['image_path'] = $targetFile; $parsed['raw_text'] = $rawText; @@ -295,9 +296,9 @@ final class OcrService return $binary !== '' && trim((string) shell_exec('command -v ' . escapeshellarg($binary) . ' 2>/dev/null')) !== ''; } - private function parseText(string $rawText, string $dateContext, string $walletCurrencyHint = ''): array + private function parseText(string $rawText, string $dateContext, string $walletCurrencyHint = '', string $miningCurrencyHint = ''): array { - $measurement = $this->parseMeasurementText($rawText, $dateContext); + $measurement = $this->parseMeasurementText($rawText, $dateContext, $miningCurrencyHint); $wallet = $this->parseWalletText($rawText, $dateContext, $walletCurrencyHint); $isWallet = ($wallet['score'] ?? 0) > ($measurement['score'] ?? 0) @@ -315,7 +316,7 @@ final class OcrService ]; } - private function parseMeasurementText(string $rawText, string $dateContext): array + private function parseMeasurementText(string $rawText, string $dateContext, string $miningCurrencyHint = ''): array { $flags = []; $suggestedTime = null; @@ -352,7 +353,11 @@ final class OcrService ]; } - if (preg_match('/DOGE\s*\/\s*(USD|EUR|USDT|USDC|BTC|ETH|LTC)/i', $normalizedText, $pairMatch)) { + $assetPattern = $miningCurrencyHint !== '' + ? preg_quote($miningCurrencyHint, '/') + : '(?:DOGE|BTC|ETH|LTC|XRP|ADA|SOL|USDT|USDC|TRX|XMR|DOT|ARB|BNB|AVAX|MATIC)'; + + if (preg_match('/' . $assetPattern . '\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]); @@ -362,7 +367,7 @@ final class OcrService $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)) { + if (preg_match('/' . $assetPattern . '\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); } @@ -380,7 +385,7 @@ final class OcrService } } - if ($coinsTotal === null && preg_match('/(\d+[.,]\d{4,8})\s*(?:DOGE)?\s*(?:MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE)/i', $normalizedText, $coinsMatch)) { + if ($coinsTotal === null && preg_match('/(\d+[.,]\d{4,8})\s*(?:' . $assetPattern . ')?\s*(?:MINING[- ]?GUTHABEN|MINING[- ]?BALANCE|GUTHABEN|BALANCE)/i', $normalizedText, $coinsMatch)) { $coinsTotal = round((float) str_replace(',', '.', $coinsMatch[1]), 6); $flags[] = 'coins_from_balance_context'; }