diff --git a/custom/apps/fx-rates/src/Infrastructure/FxRatesRepository.php b/custom/apps/fx-rates/src/Infrastructure/FxRatesRepository.php index 1daee42a..f89af542 100644 --- a/custom/apps/fx-rates/src/Infrastructure/FxRatesRepository.php +++ b/custom/apps/fx-rates/src/Infrastructure/FxRatesRepository.php @@ -386,6 +386,42 @@ final class FxRatesRepository } } + public function replaceFetchRates(int $fetchId, array $rates): void + { + if ($fetchId <= 0) { + return; + } + + $normalizedRates = []; + foreach ($rates as $currencyCode => $rate) { + $currencyCode = strtoupper(trim((string) $currencyCode)); + if ($currencyCode !== '' && is_numeric($rate) && (float) $rate > 0) { + $normalizedRates[$currencyCode] = (float) $rate; + } + } + + $this->pdo->prepare('DELETE FROM ' . $this->table('rates') . ' WHERE fetch_id = :fetch_id') + ->execute(['fetch_id' => $fetchId]); + if ($normalizedRates === []) { + return; + } + + $placeholders = []; + $params = ['fetch_id' => $fetchId]; + $index = 0; + foreach ($normalizedRates as $currencyCode => $rate) { + $codeKey = 'currency_code_' . $index; + $valueKey = 'current_value_' . $index; + $placeholders[] = "(:fetch_id, :{$codeKey}, :{$valueKey})"; + $params[$codeKey] = $currencyCode; + $params[$valueKey] = $rate; + $index++; + } + $this->pdo->prepare( + 'INSERT INTO ' . $this->table('rates') . ' (fetch_id, currency_code, current_value) VALUES ' . implode(', ', $placeholders) + )->execute($params); + } + public function findFetchByBaseAndFetchedAt(string $baseCurrency, string $fetchedAt): ?array { $baseCurrency = strtoupper(trim($baseCurrency)); diff --git a/custom/apps/mining-checker/assets/js/app.js b/custom/apps/mining-checker/assets/js/app.js index e1974243..36ace412 100644 --- a/custom/apps/mining-checker/assets/js/app.js +++ b/custom/apps/mining-checker/assets/js/app.js @@ -2319,6 +2319,29 @@ } } + async function repairHistoricalRecoveryFx() { + setSaving(true); + setError(''); + setMessage(''); + try { + await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/historical-recovery-fx`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + measured_at: recoveryForm.miner_at, + doge_usd: recoveryForm.doge_usd, + usd_eur: recoveryForm.usd_eur, + }), + }); + await loadBootstrap(projectKey); + setMessage('Historischer FX-Snapshot und zugehoeriger Miner wurden korrigiert.'); + } catch (err) { + setError(`Historischer FX-Snapshot konnte nicht korrigiert werden: ${err.message}`); + } finally { + setSaving(false); + } + } + async function submitWalletSnapshotFromPreview(previewOverride) { const preview = normalizeOcrPreview(previewOverride || ocrPreview); const raw = { @@ -3334,7 +3357,6 @@ value: earnedValue !== null ? fmtMoney(earnedValue, reportCurrency) : 'n/a', sub: [ totalHoldingsValue !== null ? `Variabel ${fmtMoney(totalHoldingsValue, reportCurrency)}` : null, - settledCryptoSpendValue !== null ? `Fix ${fmtMoney(settledCryptoSpendValue, reportCurrency)}` : null, currentSettings?.baseline_measured_at ? `Seit Baseline ${fmtDate(currentSettings.baseline_measured_at)}` : 'Benötigt Baseline', ].filter(Boolean).join(' · '), }), @@ -3487,6 +3509,12 @@ className: 'mc-button mc-button--primary', disabled: saving, }, saving ? 'Speichert …' : 'Alle Daten nachtragen'), + h('button', { + type: 'button', + className: 'mc-button mc-button--ghost', + onClick: repairHistoricalRecoveryFx, + disabled: saving, + }, 'Nur historischen FX/Miner korrigieren'), ])), ]); } diff --git a/custom/apps/mining-checker/docs/README.md b/custom/apps/mining-checker/docs/README.md index 46a83543..906dded6 100644 --- a/custom/apps/mining-checker/docs/README.md +++ b/custom/apps/mining-checker/docs/README.md @@ -142,7 +142,7 @@ Diese Regeln sind bei jeder Erweiterung der Wallet-, Miner- oder Uebersichtslogi 4. Wird ein Miner mit der Kryptowaehrung bezahlt, reduziert der Kauf den Walletbestand um den gezahlten Coin-Betrag. Gleichzeitig wird dessen Gegenwert zum Kaufzeitpunkt als feste Buchung gespeichert. Dieser Betrag darf durch spaetere Kursaenderungen nicht mehr beeinflusst werden. 5. Ein Wallet-Screenshot ist fuer seinen Zeitstempel autoritativ. Sein erkannter Hauptbestand ueberschreibt die rechnerische Wallet-Historie bis zu diesem Zeitpunkt; erst danach werden weitere Transfers, Krypto-Minerkaeufe und externe Wallet-Auszahlungen verrechnet. 6. Jede erfasste Auszahlung aus dem Mining-Tool muss den Walletbestand automatisch fortschreiben. Externe Auszahlungen aus dem hier verfolgten Wallet reduzieren ihn. -7. Der angezeigte bisherige Wert trennt variable Werte (Coins im Mining-Tool und Wallet zum aktuellen Kurs) von fixen Werten (bereits mit Krypto bezahlte Miner zum Ausgabezeitpunkt). Die Gesamtsumme besteht aus beiden Kategorien. +7. `Bisher verdient gesamt` zeigt ausschliesslich variable Werte: Coins im Mining-Tool und Wallet zum aktuellen Kurs. Bereits mit Krypto bezahlte Miner sind Ausgaben und duerfen dort niemals als fixer Ertrag erscheinen. 8. Jede neue feste Krypto-Buchung referenziert die `fetch_id` des Moduls `fx-rates`. Der Mining-Checker fuehrt keine eigene Kurstabelle. Umrechnungen in andere Berichtswährungen verwenden fuer fixe Werte denselben historischen API-Snapshot. 9. Wird ein gemieteter Miner geloescht, wird sein kompletter Datensatz entfernt: Mietkosten, feste Krypto-Bewertung, FX-Referenz und der daraus abgeleitete Wallet-Abzug entfallen. Der Miner beeinflusst danach weder Walletbestand noch Ausgaben, Reinvest, Hashrate oder Break-even. Basis-Angebote, Mining-Uploads und andere Wallet-Buchungen bleiben unveraendert. 10. `Kosten/kH/s/Tag` ist ein laufzeitbereinigter Vergleichswert: feste historische Gesamtkosten geteilt durch die gesamte Hashrate einschliesslich Bonus und die exakten Kalendertage zwischen Mietzeitpunkt und Laufzeitende. Das Laufzeitende entsteht durch das Addieren der gebuchten Kalendermonate; bei kuerzeren Zielmonaten wird auf deren letzten Kalendertag begrenzt. Fuer Krypto-Mieten sind das `settled_value_amount / (Basis-Hashrate + Bonus-Hashrate) / Laufzeittage` in `settled_value_currency`; der aktuelle Coin-Kurs und ein Angebots-Referenzpreis duerfen diese Kennzahl nicht beeinflussen. Fuer FIAT-Mieten gilt derselbe Quotient mit dem tatsaechlich gezahlten FIAT-Betrag und dessen Zahlungswaehrung. @@ -175,6 +175,8 @@ Fehlt ein exakter historischer FX-Snapshot, darf eine feste Krypto-Ausgabe nicht Fuer zusammenhaengende, belegbare Ausfaelle steht der Bereich `Nachtragen` bereit. Er legt zuerst einen historischen `fx-rates`-Snapshot an und speichert danach in fester Reihenfolge Krypto-Miete, Mining-Tool-Transfer, Wallet-Snapshot und Mining-Messpunkt. Wiederholte Ausfuehrung des Assistenten verwendet den FX-Snapshot mit identischem Zeitpunkt erneut. Die fachlichen Buchungen selbst duerfen nach einer erfolgreichen Ausfuehrung nicht erneut gestartet werden, damit keine doppelten Miner oder Transfers entstehen. +Kursnotierung und Speicherung werden getrennt behandelt: Die Eingabe `DOGE/USD` ist USD pro DOGE. Ein `fx-rates`-Snapshot mit Basis USD speichert dagegen DOGE pro USD, also den Kehrwert. Der Nachtrag-Assistent erledigt diese Umrechnung vor dem Speichern. `Nur historischen FX/Miner korrigieren` repariert ausschliesslich diesen Snapshot und die feste Bewertung eines bereits angelegten passenden Miners; Transfers und Messpunkte werden dabei nicht erneut angelegt. + ## Break-even Der Gesamt-Break-even verwendet die Summe aus Fiat-Investitionen und `Reinvest fix` gegen aktuelle, noch gehaltene Miner- und Walletbestaende. Krypto-Reinvestitionen muessen immer den historischen festen Gegenwert aus dem zugeordneten `fx-rates`-Snapshot verwenden; eine Bewertung zum aktuellen Coin-Kurs ist unzulässig. diff --git a/custom/apps/mining-checker/src/Api/Router.php b/custom/apps/mining-checker/src/Api/Router.php index 1187c6fc..303815bc 100644 --- a/custom/apps/mining-checker/src/Api/Router.php +++ b/custom/apps/mining-checker/src/Api/Router.php @@ -854,11 +854,16 @@ final class Router $measuredAt = $this->requiredDateTime($input['measured_at'] ?? null, 'measured_at', $this->projectTimezone($projectKey)); $dogeUsd = $this->requiredDecimal($input['doge_usd'] ?? null, 'doge_usd'); $usdEur = $this->requiredDecimal($input['usd_eur'] ?? null, 'usd_eur'); + // fx-rates stores target units per base unit. DOGE/USD is supplied as USD per DOGE. + $rates = ['DOGE' => 1 / $dogeUsd, 'EUR' => $usdEur]; $existing = $fxRepository->findFetchByBaseAndFetchedAt('USD', $measuredAt); if (is_array($existing) && is_numeric($existing['id'] ?? null)) { + $fetchId = (int) $existing['id']; + $fxRepository->replaceFetchRates($fetchId, $rates); + $this->repairHistoricalRecoveryMinerSettlement($projectKey, $measuredAt, $dogeUsd, $usdEur, $fetchId); return [ - 'fetch_id' => (int) $existing['id'], + 'fetch_id' => $fetchId, 'reused' => true, 'fetched_at' => $measuredAt, ]; @@ -868,19 +873,39 @@ final class Router 'USD', 'coingecko+valutafx', substr($measuredAt, 0, 10), - ['DOGE' => $dogeUsd, 'EUR' => $usdEur], + $rates, $measuredAt, 'migration' ); + $fetchId = is_numeric($saved['fetch']['id'] ?? null) ? (int) $saved['fetch']['id'] : null; + if ($fetchId !== null) { + $this->repairHistoricalRecoveryMinerSettlement($projectKey, $measuredAt, $dogeUsd, $usdEur, $fetchId); + } + return [ - 'fetch_id' => is_numeric($saved['fetch']['id'] ?? null) ? (int) $saved['fetch']['id'] : null, + 'fetch_id' => $fetchId, 'reused' => false, 'fetched_at' => $measuredAt, 'provider' => 'coingecko+valutafx', ]; } + private function repairHistoricalRecoveryMinerSettlement(string $projectKey, string $purchasedAt, float $dogeUsd, float $usdEur, int $fxFetchId): void + { + foreach ($this->repository()->listPurchasedMiners($projectKey) as $miner) { + if ((string) ($miner['purchased_at'] ?? '') !== $purchasedAt || strtoupper((string) ($miner['currency'] ?? '')) !== 'DOGE') { + continue; + } + $spent = is_numeric($miner['total_cost_amount'] ?? null) ? (float) $miner['total_cost_amount'] : null; + $minerId = is_numeric($miner['id'] ?? null) ? (int) $miner['id'] : 0; + if ($spent === null || $spent <= 0 || $minerId <= 0) { + continue; + } + $this->repository()->updatePurchasedMinerSettlement($projectKey, $minerId, $spent * $dogeUsd * $usdEur, 'EUR', $fxFetchId); + } + } + private function offerBasisHistory(string $projectKey): array { return $this->repository()->listOfferBasisHistory($projectKey, 50); diff --git a/custom/apps/mining-checker/src/Domain/AnalyticsService.php b/custom/apps/mining-checker/src/Domain/AnalyticsService.php index b1f8ef87..fb8397ee 100644 --- a/custom/apps/mining-checker/src/Domain/AnalyticsService.php +++ b/custom/apps/mining-checker/src/Domain/AnalyticsService.php @@ -461,15 +461,11 @@ final class AnalyticsService // Reinvestments are spent coins, not a current crypto holding. Their historical // settlement value is therefore the only valid break-even display basis. $reinvestedCapital = $settledCryptoSpendValue; - $settledCryptoSpendCurrentAsset = $latestAsset !== '' - ? $this->settledCryptoSpendValue($purchasedMiners, $latestAsset, $latest) - : null; - $earnedCurrentAsset = $settledCryptoSpendCurrentAsset !== null - ? $holdingsCurrentAsset + $settledCryptoSpendCurrentAsset - : $holdingsCurrentAsset; - $earnedValue = ($totalHoldingsValue !== null || $settledCryptoSpendValue !== null) - ? (float) ($totalHoldingsValue ?? 0.0) + (float) ($settledCryptoSpendValue ?? 0.0) - : null; + // Income is only the current value of coins still held in the miner or wallet. + // A crypto-paid miner is an expense and must never be added as fixed income. + $settledCryptoSpendCurrentAsset = null; + $earnedCurrentAsset = $holdingsCurrentAsset; + $earnedValue = $totalHoldingsValue; $currentDailyRevenue = is_numeric($latest['theoretical_daily_revenue'] ?? null) ? (float) $latest['theoretical_daily_revenue'] : null; $totalInvestedCapital = ($cashInvestedCapital !== null || $reinvestedCapital !== null) ? (float) ($cashInvestedCapital ?? 0.0) + (float) ($reinvestedCapital ?? 0.0) diff --git a/custom/apps/mining-checker/src/Infrastructure/MiningRepository.php b/custom/apps/mining-checker/src/Infrastructure/MiningRepository.php index 7dc41212..007434b5 100644 --- a/custom/apps/mining-checker/src/Infrastructure/MiningRepository.php +++ b/custom/apps/mining-checker/src/Infrastructure/MiningRepository.php @@ -1144,6 +1144,25 @@ final class MiningRepository return $this->normalizeRow($fetch->fetch() ?: []); } + public function updatePurchasedMinerSettlement(string $projectKey, int $minerId, float $amount, string $currency, int $fxFetchId): ?array + { + $stmt = $this->pdo->prepare( + 'UPDATE ' . $this->table('purchased_miners') . ' + SET settled_value_amount = :amount, settled_value_currency = :currency, settled_fx_fetch_id = :fx_fetch_id + WHERE project_key = :project_key AND owner_sub = :owner_sub AND id = :id' + ); + $stmt->execute([ + 'amount' => $amount, + 'currency' => strtoupper(trim($currency)), + 'fx_fetch_id' => $fxFetchId, + 'project_key' => $projectKey, + 'owner_sub' => $this->ownerSub, + 'id' => $minerId, + ]); + + return $this->getPurchasedMiner($projectKey, $minerId); + } + public function deletePurchasedMiner(string $projectKey, int $minerId): void { if ($minerId <= 0) { diff --git a/docs/CONTENT.md b/docs/CONTENT.md index e3df998e..6ad71214 100644 --- a/docs/CONTENT.md +++ b/docs/CONTENT.md @@ -137,6 +137,7 @@ Aus [custom/apps/README.md](/home/lars/Schreibtisch/Projekte/desktop.kusche.berl - `Mining-Checker` ist eine Desktop-App aus `custom/apps/mining-checker/` - Historische Mining-Nachtraege verwenden ihren belegten Zeitpunkt; fuer feste Krypto-Buchungen ist ein zeitnaher, referenzierter `fx-rates`-Snapshot Pflicht und ein aktueller Kurs darf keinen historischen Snapshot ersetzen. - Der Bereich `Nachtragen` im Mining-Checker speichert zusammenhaengende historische Miner-, Transfer-, Wallet- und Mining-Daten in chronologischer Reihenfolge und legt den belegten FX-Snapshot davor an. +- `Bisher verdient` besteht ausschliesslich aus variablen Coin-Bestaenden; fixe Krypto-Mieten sind historische Ausgaben und gehoeren nicht in den Ertrag. - `Waehrungs-Checker` ist eine Desktop-App aus `custom/apps/fx-rates/` - die Desktop-Shell kann Moduldefinitionen zentral erkennen und als `App` bereitstellen - wiederverwendbare Modul-Helfer liegen im globalen Kern, die Fachlogik bleibt im Modul