Miner-Upgrade
All checks were successful
Deploy / deploy-staging (push) Successful in 7s
Deploy / deploy-production (push) Has been skipped

This commit is contained in:
2026-05-09 00:58:48 +02:00
parent ee5a46254f
commit fc95898a9d
11 changed files with 976 additions and 131 deletions

View File

@@ -344,6 +344,7 @@
purchased_miners: [],
measurement_rates: [],
},
wallet_snapshots: Array.isArray(normalized.wallet_snapshots) ? normalized.wallet_snapshots : [],
measurements: Array.isArray(normalized.measurements) ? normalized.measurements : [],
targets: Array.isArray(normalized.targets) ? normalized.targets : [],
dashboards: Array.isArray(normalized.dashboards) ? normalized.dashboards : [],
@@ -352,7 +353,15 @@
latest_measurement: null,
baseline: normalized.settings || null,
targets: Array.isArray(normalized.targets) ? normalized.targets : [],
payouts: { total_count: 0, total_coins: 0, current_visible_coins: null, current_effective_coins: null },
payouts: {
total_count: 0,
total_coins: 0,
current_visible_coins: null,
current_effective_coins: null,
wallet_balances: {},
wallet_balance_current_asset: null,
holdings_current_asset: null,
},
current_hashrate_mh: null,
miner_offers: [],
},
@@ -380,6 +389,7 @@
: {};
return {
kind: normalized.kind === 'wallet' ? 'wallet' : 'measurement',
suggested: {
measured_at: suggested.measured_at || '',
coins_total: suggested.coins_total ?? '',
@@ -388,6 +398,29 @@
note: suggested.note || '',
source: suggested.source || 'image_ocr',
},
suggested_wallet: normalized.suggested_wallet && typeof normalized.suggested_wallet === 'object'
? {
measured_at: normalized.suggested_wallet.measured_at || '',
total_value_amount: normalized.suggested_wallet.total_value_amount ?? '',
total_value_currency: normalized.suggested_wallet.total_value_currency || '',
wallet_balance: normalized.suggested_wallet.wallet_balance ?? '',
wallet_currency: normalized.suggested_wallet.wallet_currency || '',
balances_json: normalized.suggested_wallet.balances_json && typeof normalized.suggested_wallet.balances_json === 'object'
? normalized.suggested_wallet.balances_json
: {},
note: normalized.suggested_wallet.note || '',
source: normalized.suggested_wallet.source || 'image_ocr',
}
: {
measured_at: '',
total_value_amount: '',
total_value_currency: '',
wallet_balance: '',
wallet_currency: '',
balances_json: {},
note: '',
source: 'image_ocr',
},
confidence: typeof normalized.confidence === 'number' ? normalized.confidence : 0,
flags: Array.isArray(normalized.flags) ? normalized.flags : [],
image_path: normalized.image_path || '',
@@ -790,6 +823,7 @@
];
const currentCostPlans = Array.isArray(currentSettings.cost_plans) ? currentSettings.cost_plans : [];
const currentPayouts = Array.isArray(currentSettings.payouts) ? currentSettings.payouts : [];
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 : [];
const renewableOfferIds = new Set(
@@ -925,13 +959,13 @@
setPurchaseMinerForm((current) => ({
...current,
offer_id: current.offer_id || String(selectedOffer.id),
currency: current.currency || selectedOffer.effective_price_currency || selectedOffer.base_price_currency || 'USD',
currency: current.currency || (!selectedOffer.auto_renew ? (currentSettings.crypto_currency || 'DOGE') : (selectedOffer.effective_price_currency || selectedOffer.base_price_currency || 'USD')),
total_cost_amount: current.total_cost_amount || (selectedOffer.effective_price_amount !== null && selectedOffer.effective_price_amount !== undefined ? String(selectedOffer.effective_price_amount) : ''),
reference_price_amount: current.reference_price_amount || (selectedOffer.reference_price_amount !== null && selectedOffer.reference_price_amount !== undefined ? String(selectedOffer.reference_price_amount) : ''),
reference_price_currency: current.reference_price_currency || selectedOffer.reference_price_currency || '',
auto_renew: current.auto_renew || !!selectedOffer.auto_renew,
}));
}, [purchaseMinerModalOpen, availableMinerOffers, purchaseMinerForm.offer_id]);
}, [purchaseMinerModalOpen, availableMinerOffers, purchaseMinerForm.offer_id, currentSettings.crypto_currency]);
function measurementFxRate(measurementId, fromCurrency, toCurrency) {
const from = String(fromCurrency || '').toUpperCase();
@@ -1167,6 +1201,7 @@
}, [payload, projectKey]);
const overviewCharts = useMemo(() => {
const chartCoinCurrency = String(currentSettings.crypto_currency || 'DOGE').toUpperCase();
const comparisonRows = measurements.filter((row) => {
const miningRate = Number(row.doge_per_hour_per_mh_interval);
const price = Number(row.effective_price_per_coin ?? row.price_per_coin);
@@ -1185,7 +1220,7 @@
miningVsPrice: baseMining && basePrice ? [
{
key: 'mining-rate',
label: 'Mining/h je MH/s Index',
label: `${chartCoinCurrency}/h je MH/s Index`,
color: '#2dd4bf',
data: comparisonRows.map((row) => ({
x: fmtDate(row.measured_at),
@@ -1194,7 +1229,7 @@
},
{
key: 'doge-price',
label: 'DOGE-Kurs Index',
label: `${chartCoinCurrency}-Kurs Index`,
color: '#f59e0b',
data: comparisonRows.map((row) => ({
x: fmtDate(row.measured_at),
@@ -1203,7 +1238,7 @@
},
] : [],
};
}, [measurements]);
}, [currentSettings.crypto_currency, measurements]);
async function submitMeasurement(fromPreview) {
const preview = normalizeOcrPreview(ocrPreview);
@@ -1242,6 +1277,35 @@
}
}
async function submitWalletSnapshotFromPreview() {
const preview = normalizeOcrPreview(ocrPreview);
const raw = {
...preview.suggested_wallet,
image_path: preview.image_path,
ocr_raw_text: preview.raw_text,
ocr_confidence: preview.confidence,
ocr_flags: preview.flags,
};
setSaving(true);
setError('');
setMessage('');
try {
await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/wallet-snapshots`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(raw),
});
setMessage('Wallet-Snapshot gespeichert.');
setOcrPreview(null);
await loadBootstrap(projectKey);
} catch (err) {
setError(err.message);
} finally {
setSaving(false);
}
}
async function deleteMeasurement(id) {
if (!id) {
return;
@@ -1320,6 +1384,7 @@
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');
const data = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/ocr-preview`, {
method: 'POST',
body,
@@ -1534,7 +1599,7 @@
body: JSON.stringify(payoutForm),
});
setMessage('Auszahlung gespeichert.');
setPayoutForm({ payout_at: '', coins_amount: '', payout_currency: 'DOGE', note: '' });
setPayoutForm({ payout_at: '', coins_amount: '', payout_currency: currentSettings.crypto_currency || 'DOGE', note: '' });
setPayoutModalOpen(false);
await loadBootstrap(projectKey);
} catch (err) {
@@ -1792,6 +1857,75 @@
setReportCurrencyOverride('');
setCookie('mining_checker_report_currency', '', 0);
}
function renderSharedOcrPanel() {
const preview = normalizeOcrPreview(ocrPreview);
const isWalletPreview = preview.kind === 'wallet';
const hasUsableOcrSuggestion = isWalletPreview
? (preview.suggested_wallet.wallet_balance !== '' && preview.suggested_wallet.wallet_balance !== null)
: (preview.suggested.coins_total !== '' && preview.suggested.coins_total !== null);
const ocrStatus = getOcrStatusMessage(preview);
return panel('OCR Upload', 'Screenshot auswaehlen, Ergebnis direkt pruefen und speichern.', [
h('div', {
key: 'ocr-form',
className: 'mc-form',
}, [
fileField('Screenshot', (file) => loadOcrPreview(file, { image: file })),
]),
saving && ocrForm.image
? h('div', { key: 'ocr-loading', className: 'mc-empty' }, 'Analysiere Screenshot …')
: null,
ocrPreview
? h('div', { key: 'ocr-preview', className: 'mc-form' }, [
h('div', { key: 'badges', className: 'mc-inline-row' }, [
h(Badge, { key: 'kind', tone: isWalletPreview ? 'info' : 'success' }, isWalletPreview ? 'Wallet' : 'Mining'),
h(Badge, { key: 'confidence', tone: preview.confidence >= 0.75 ? 'success' : 'warn' }, `confidence ${fmtNumber(preview.confidence, 4)}`),
]),
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('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'),
displayField('Erkannte Wallet-Assets', Object.entries(preview.suggested_wallet.balances_json || {})
.slice(0, 8)
.map(([code, amount]) => `${fmtNumber(amount, 8)} ${code}`)
.join(' · ') || 'n/a'),
])
: h('div', { key: 'measurement-form', className: 'mc-two-col' }, [
displayField('Erkannter Typ', 'Mining-Messpunkt'),
displayField('Datum/Zeit', 'Wird beim Speichern auf den aktuellen Bestätigungszeitpunkt gesetzt.'),
displayField('Coins total', fmtNumber(preview.suggested.coins_total, 6)),
displayField('Kurs', fmtNumber(preview.suggested.price_per_coin, 6)),
displayField('Waehrung', preview.suggested.price_currency || 'n/a'),
]),
ocrStatus
? h('div', {
key: 'ocr-status',
className: cx(
'mc-alert',
ocrStatus.tone === 'error' ? 'mc-alert--error' : 'mc-alert--warning'
),
}, ocrStatus.text)
: null,
!hasUsableOcrSuggestion
? h('div', { key: 'ocr-warning', className: 'mc-alert mc-alert--error' },
'Kein verwertbarer OCR-Vorschlag erkannt. Bitte Bild erneut hochladen oder die Daten manuell erfassen.')
: null,
h('button', {
key: 'confirm',
type: 'button',
className: 'mc-button mc-button--primary',
onClick: () => isWalletPreview ? submitWalletSnapshotFromPreview() : submitMeasurement(true),
disabled: saving || !hasUsableOcrSuggestion,
}, saving ? 'Speichert …' : 'Ergebnis speichern'),
])
: h('div', { key: 'ocr-empty', className: 'mc-empty' },
'Noch kein Screenshot ausgewaehlt.'),
]);
}
return h('div', {
className: 'mc-grid-bg',
}, [
@@ -1804,6 +1938,9 @@
]);
function renderTab() {
const currentCoinCurrency = String((latest && latest.coin_currency) || currentSettings.crypto_currency || 'DOGE').toUpperCase();
const perDayLabel = `${currentCoinCurrency} pro Tag`;
if (activeTab === 'overview') {
const latestValue = latest ? convertMeasurementMoney(latest, latest.current_value, reportCurrency) : null;
const latestPriceSource = latest && latest.effective_price_per_coin !== null && latest.effective_price_per_coin !== undefined
@@ -1822,18 +1959,14 @@
const breakEvenDaysOverall = latest && latest.break_even_days_overall !== null && latest.break_even_days_overall !== undefined
? Number(latest.break_even_days_overall)
: null;
const investedCapital = latest ? convertMeasurementMoney(latest, latest.invested_capital, reportCurrency) : null;
const investedCapital = latest ? convertMeasurementMoney(latest, latest.cash_invested_capital ?? latest.invested_capital, reportCurrency) : null;
const reinvestedCapital = latest ? convertMeasurementMoney(latest, latest.reinvested_capital, reportCurrency) : null;
const walletValue = latest ? convertMeasurementMoney(latest, latest.wallet_value, reportCurrency) : null;
const totalHoldingsValue = latest ? convertMeasurementMoney(latest, latest.total_holdings_value, reportCurrency) : null;
const breakEvenReached = breakEvenRemainingAmount !== null && breakEvenRemainingAmount <= 0;
const breakEvenDate = (() => {
if (!latest || breakEvenDaysOverall === null || !Number.isFinite(breakEvenDaysOverall)) {
return null;
}
const baseTimestamp = Date.parse(String(latest.measured_at || ''));
if (!Number.isFinite(baseTimestamp)) {
return null;
}
return new Date(baseTimestamp + (breakEvenDaysOverall * 24 * 60 * 60 * 1000));
})();
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 holdingsCurrentAsset = payload?.summary?.payouts?.holdings_current_asset;
return h('div', { className: 'mc-stack' }, [
panel('Berichtswährung', 'Bestimmt die Währung für Kennzahlen im Überblick. Standard kommt aus den Settings, diese Auswahl gilt nur für den aktuellen Besuch.', [
@@ -1855,19 +1988,21 @@
h('div', { key: 'stats', className: 'mc-stats-grid' }, [
h(StatCard, {
key: 'coins',
label: 'Coins sichtbar',
label: `${currentCoinCurrency} im Miner`,
value: latest ? fmtNumber(latest.coins_total_visible || latest.coins_total, 6) : 'n/a',
sub: latest ? `Stand ${fmtDate(latest.measured_at)}` : '',
}),
h(StatCard, {
key: 'coins-effective',
label: 'Coins effektiv',
value: payload?.summary?.payouts ? fmtNumber(payload.summary.payouts.current_effective_coins, 6) : 'n/a',
sub: payload?.summary?.payouts ? `Ausgezahlt ${fmtNumber(payload.summary.payouts.total_coins, 6)} DOGE` : '',
key: 'holdings',
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}`
: '',
}),
h(StatCard, {
key: 'perday',
label: 'DOGE pro Tag',
label: perDayLabel,
value: latest ? fmtNumber(latest.doge_per_day_interval, 4) : 'n/a',
sub: payload?.summary?.current_hashrate_mh ? `Hashrate ${fmtNumber(payload.summary.current_hashrate_mh, 4)} MH/s` : (latest ? `Trend ${latest.trend_label}` : ''),
}),
@@ -1884,17 +2019,19 @@
label: 'Theoretischer Tagesgewinn',
value: dailyProfit !== null ? fmtMoney(dailyProfit, reportCurrency) : 'n/a',
sub: dailyCost !== null
? `Tageskosten ${fmtMoney(dailyCost, reportCurrency)} · Break-even ${breakEvenPrice !== null ? `${fmtNumber(breakEvenPrice, 6)} ${reportCurrency}` : 'n/a'}`
? `Tageskosten ${fmtMoney(dailyCost, reportCurrency)} · Walletwert ${walletValue !== null ? fmtMoney(walletValue, reportCurrency) : 'n/a'}`
: 'Kein aktiver Miner fuer diese Waehrung',
}),
h(StatCard, {
key: 'break-even-point',
label: 'Break-even',
value: breakEvenDaysOverall !== null
label: 'Cash-Break-even',
value: breakEvenReached
? 'Erreicht'
: breakEvenDaysOverall !== null
? `${fmtNumber(breakEvenDaysOverall, 2)} Tage`
: (breakEvenReached ? 'Erreicht' : (investedCapital === null ? 'Keine Mietbasis' : 'n/a')),
: (investedCapital === null ? 'Keine Mietbasis' : 'Nicht erreichbar'),
sub: investedCapital !== null
? `${breakEvenDate ? `Theoretisch ${fmtDate(breakEvenDate.toISOString())} · ` : ''}Basis ${fmtMoney(investedCapital, reportCurrency)}${dailyRevenue !== null ? ` · Tagesumsatz ${fmtMoney(dailyRevenue, reportCurrency)}` : ''}`
? `${breakEvenEta ? `ETA ${breakEvenEta} · ` : ''}Cash ${fmtMoney(investedCapital, reportCurrency)}${reinvestedCapital !== null ? ` · Reinvest ${fmtMoney(reinvestedCapital, reportCurrency)}` : ''}${totalHoldingsValue !== null ? ` · Bestand ${fmtMoney(totalHoldingsValue, reportCurrency)}` : ''}`
: (breakEvenPrice !== null
? `Break-even-Kurs ${fmtNumber(breakEvenPrice, 6)} ${reportCurrency}`
: (investedCapital === null
@@ -1904,14 +2041,14 @@
]),
h('div', { key: 'charts', className: 'mc-overview-grid' }, [
panel('Mining-Verlauf', 'Coins total der letzten 15 Tage.', h(SimpleChart, { type: 'line', data: overviewCharts.mining })),
panel('Performance-Verlauf', 'DOGE-pro-Tag-Raten der letzten 15 Tage.', h(SimpleChart, { type: 'area', data: overviewCharts.performance })),
panel('Performance-Verlauf', `${perDayLabel}-Raten der letzten 15 Tage.`, h(SimpleChart, { type: 'area', data: overviewCharts.performance })),
panel('Kurs-Verlauf', 'Preiswerte der letzten 15 Tage.', h(SimpleChart, { type: 'line', data: overviewCharts.pricing })),
panel('Mining vs. Kurs', 'Index-Vergleich der letzten 15 Tage. Mining wird auf DOGE pro Stunde je MH/s normalisiert, beide Reihen starten bei 100.', h(SimpleChart, {
panel('Mining vs. Kurs', `Index-Vergleich der letzten 15 Tage. Mining wird auf ${currentCoinCurrency} pro Stunde je MH/s normalisiert, beide Reihen starten bei 100.`, h(SimpleChart, {
type: 'line',
series: overviewCharts.miningVsPrice,
})),
]),
panel('Zielmonitor', 'Rest-DOGE und Resttage werden gegen den letzten verfuegbaren Kurs je Zielwaehrung berechnet.',
panel('Zielmonitor', `Rest-${currentCoinCurrency} und Resttage werden gegen den letzten verfuegbaren Kurs je Zielwaehrung berechnet.`,
h('div', { className: 'mc-target-grid' },
currentTargets.map((target, index) => h('div', {
key: index,
@@ -1924,8 +2061,8 @@
h('div', { key: 'body', className: 'mc-text mc-target-grid' }, [
h('div', { key: 'amount' }, `Ziel: ${fmtMoney(target.target_amount_fiat, target.currency)}`),
h('div', { key: 'price' }, `Letzter Kurs: ${target.latest_price_for_currency ? fmtNumber(target.latest_price_for_currency, 6) + ' ' + target.currency : 'n/a'}`),
h('div', { key: 'doge' }, `Benoetigte DOGE: ${fmtNumber(target.required_doge, 6)}`),
h('div', { key: 'remaining' }, `Rest-DOGE: ${fmtNumber(target.remaining_doge, 6)}`),
h('div', { key: 'doge' }, `Benoetigte ${currentCoinCurrency}: ${fmtNumber(target.required_doge, 6)}`),
h('div', { key: 'remaining' }, `Rest-${currentCoinCurrency}: ${fmtNumber(target.remaining_doge, 6)}`),
h('div', { key: 'days' }, `Resttage: ${fmtNumber(target.remaining_days, 4)}`),
]),
]))
@@ -1937,56 +2074,7 @@
if (activeTab === 'measurements') {
return h('div', { className: 'mc-main-grid' }, [
h('div', { className: 'mc-stack' }, [
(function () {
const preview = normalizeOcrPreview(ocrPreview);
const hasUsableOcrSuggestion = preview.suggested.coins_total !== '' && preview.suggested.coins_total !== null;
const ocrStatus = getOcrStatusMessage(preview);
return panel('OCR Upload', 'Screenshot auswaehlen, Ergebnis direkt pruefen und speichern.', [
h('div', {
key: 'ocr-form',
className: 'mc-form',
}, [
fileField('Screenshot', (file) => loadOcrPreview(file, { image: file })),
]),
saving && ocrForm.image
? h('div', { key: 'ocr-loading', className: 'mc-empty' }, 'Analysiere Screenshot …')
: null,
ocrPreview
? h('div', { key: 'ocr-preview', className: 'mc-form' }, [
h('div', { key: 'badges', className: 'mc-inline-row' }, [
h(Badge, { key: 'confidence', tone: preview.confidence >= 0.75 ? 'success' : 'warn' }, `confidence ${fmtNumber(preview.confidence, 4)}`),
]),
h('div', { key: 'form', className: 'mc-two-col' }, [
displayField('Datum/Zeit', 'Wird beim Speichern auf den aktuellen Bestätigungszeitpunkt gesetzt.'),
displayField('Coins total', fmtNumber(preview.suggested.coins_total, 6)),
displayField('Kurs', fmtNumber(preview.suggested.price_per_coin, 6)),
displayField('Waehrung', preview.suggested.price_currency || 'n/a'),
]),
ocrStatus
? h('div', {
key: 'ocr-status',
className: cx(
'mc-alert',
ocrStatus.tone === 'error' ? 'mc-alert--error' : 'mc-alert--warning'
),
}, ocrStatus.text)
: null,
!hasUsableOcrSuggestion
? h('div', { key: 'ocr-warning', className: 'mc-alert mc-alert--error' },
'Kein verwertbarer OCR-Vorschlag erkannt. Bitte Bild erneut hochladen oder den Messpunkt manuell erfassen.')
: null,
h('button', {
key: 'confirm',
type: 'button',
className: 'mc-button mc-button--primary',
onClick: () => submitMeasurement(true),
disabled: saving || !hasUsableOcrSuggestion,
}, saving ? 'Speichert …' : 'Ergebnis speichern'),
])
: h('div', { key: 'ocr-empty', className: 'mc-empty' },
'Noch kein Screenshot ausgewaehlt.'),
]);
})(),
renderSharedOcrPanel(),
panel('Messpunkt manuell erfassen', 'Direkte Eingabe eines einzelnen Messpunkts mit serverseitiger Validierung.', h('form', {
className: 'mc-form',
onSubmit: function (event) {
@@ -2068,12 +2156,12 @@
panel('Messhistorie', 'Die letzten 10 Uploads inkl. Performance-Werten und OCR-Metadaten.', h('div', { className: 'mc-table-shell' }, [
h('table', { key: 'table', className: 'mc-table' }, [
h('thead', { key: 'thead' }, h('tr', null, [
'Zeit', 'Coins', 'Kurs', 'Quelle', 'DOGE/Tag', 'Trend', 'Notiz', 'Aktion'
'Zeit', 'Coins', 'Kurs', 'Quelle', perDayLabel, 'Trend', 'Notiz', 'Aktion'
].map((label) => h('th', { key: label }, label)))),
h('tbody', { key: 'tbody' },
measurements.slice(-10).reverse().map((row) => h('tr', { key: row.id }, [
h('td', { key: 'measured' }, fmtDate(row.measured_at)),
h('td', { key: 'coins' }, fmtNumber(row.coins_total, 6)),
h('td', { key: 'coins' }, `${fmtNumber(row.coins_total, 6)} ${row.coin_currency || currentCoinCurrency}`),
h('td', { key: 'price' }, row.price_per_coin ? `${fmtNumber(row.price_per_coin, 6)} ${row.price_currency}` : 'n/a'),
h('td', { key: 'source' }, row.source),
h('td', { key: 'rate' }, fmtNumber(row.doge_per_day_interval, 4)),
@@ -2092,6 +2180,33 @@
]);
}
if (activeTab === 'wallet') {
return h('div', { className: 'mc-main-grid' }, [
h('div', { className: 'mc-stack' }, [
renderSharedOcrPanel(),
panel('Wallet-Historie', `Erkannte Wallet-Snapshots. Fuer Reinvestitionen ist aktuell ${currentSettings.crypto_currency || 'DOGE'} entscheidend.`, h('div', { className: 'mc-table-shell' }, [
h('table', { key: 'wallet-table', className: 'mc-table' }, [
h('thead', { key: 'thead' }, h('tr', null, [
'Zeit', 'Mining-Waehrung', 'Bestand', 'Gesamtwert', 'Quelle', 'Assets'
].map((label) => h('th', { key: label }, label)))),
h('tbody', { key: 'tbody' },
currentWalletSnapshots.length
? currentWalletSnapshots.map((row) => h('tr', { key: row.id }, [
h('td', { key: 'measured' }, fmtDate(row.measured_at)),
h('td', { key: 'currency' }, row.wallet_currency || currentSettings.crypto_currency || 'DOGE'),
h('td', { key: 'balance' }, row.wallet_balance !== null && row.wallet_balance !== undefined ? `${fmtNumber(row.wallet_balance, 8)} ${row.wallet_currency || ''}`.trim() : 'n/a'),
h('td', { key: 'value' }, row.total_value_amount !== null && row.total_value_amount !== undefined ? `${fmtNumber(row.total_value_amount, 4)} ${row.total_value_currency || ''}`.trim() : 'n/a'),
h('td', { key: 'source' }, row.source || 'manual'),
h('td', { key: 'assets' }, Object.entries(row.balances_json || {}).slice(0, 6).map(([code, amount]) => `${fmtNumber(amount, 8)} ${code}`).join(' · ') || 'n/a'),
]))
: [h('tr', { key: 'empty' }, h('td', { colSpan: 6 }, 'Noch keine Wallet-Snapshots gespeichert.'))]
),
]),
])),
]),
]);
}
if (activeTab === 'dashboards') {
return h('div', { className: 'mc-main-grid' }, [
panel('Dashboard-Builder V1', 'Chart-Typ, X/Y-Feld, Aggregation und einfache Filter werden gespeichert.', h('form', {
@@ -2194,7 +2309,15 @@
key: 'add-payout',
type: 'button',
className: 'mc-button mc-button--secondary',
onClick: () => setPayoutModalOpen(true),
onClick: () => {
setPayoutForm((previous) => ({
payout_at: previous.payout_at || nowDateTimeLocalValue(),
coins_amount: previous.coins_amount || '',
payout_currency: currentCoinCurrency,
note: previous.note || '',
}));
setPayoutModalOpen(true);
},
}, 'Auszahlung erfassen'),
]),
h('div', { key: 'payout-list', className: 'mc-table-shell' }, [
@@ -2254,7 +2377,7 @@
])
: null,
]),
h('td', { key: 'day' }, offer.expected_doge_per_day !== null ? `${fmtNumber(offer.expected_doge_per_day, 6)} DOGE` : 'n/a'),
h('td', { key: 'day' }, offer.expected_doge_per_day !== null ? `${fmtNumber(offer.expected_doge_per_day, 6)} ${currentCoinCurrency}` : 'n/a'),
h('td', { key: 'break' }, offer.break_even_days !== null ? `${fmtNumber(offer.break_even_days, 2)} Tage` : 'n/a'),
h('td', { key: 'rec' }, [
h('div', { key: 'rec-main' }, offer.recommendation),
@@ -2326,11 +2449,11 @@
}),
h(StatCard, {
key: 'scenario-doge',
label: 'DOGE pro Tag Neu',
label: `${currentCoinCurrency} pro Tag Neu`,
value: selectedMinerScenario.scenario_doge_per_day !== null ? fmtNumber(selectedMinerScenario.scenario_doge_per_day, 4) : 'n/a',
sub: selectedMinerScenario.scenario_current_doge_per_day !== null
? `Aktuell ${fmtNumber(selectedMinerScenario.scenario_current_doge_per_day, 4)}`
: 'Keine aktuelle DOGE/Tag-Basis',
: `Keine aktuelle ${currentCoinCurrency}/Tag-Basis`,
}),
h(StatCard, {
key: 'scenario-break-even',
@@ -2445,7 +2568,7 @@
h('form', { key: 'form', className: 'mc-form', onSubmit: submitPayout }, [
inputField('Auszahlungszeitpunkt', 'datetime-local', payoutForm.payout_at, (value) => setPayoutForm({ ...payoutForm, payout_at: value })),
inputField('Coins', 'number', payoutForm.coins_amount, (value) => setPayoutForm({ ...payoutForm, coins_amount: value }), '0.000001'),
selectField('Waehrung', payoutForm.payout_currency, ['DOGE'].concat(selectableCurrencies.map((currency) => currency.code).filter((code) => code !== 'DOGE')), (value) => setPayoutForm({ ...payoutForm, payout_currency: value })),
selectField('Waehrung', payoutForm.payout_currency, [currentCoinCurrency].concat(selectableCurrencies.map((currency) => currency.code).filter((code) => code !== currentCoinCurrency)), (value) => setPayoutForm({ ...payoutForm, payout_currency: value })),
textareaField('Notiz', payoutForm.note, (value) => setPayoutForm({ ...payoutForm, note: value })),
h('div', { className: 'mc-inline-row' }, [
h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setPayoutModalOpen(false) }, 'Abbrechen'),

View File

@@ -8,6 +8,7 @@
"sections": [
{ "key": "overview", "label": "Ueberblick" },
{ "key": "measurements", "label": "Messpunkte" },
{ "key": "wallet", "label": "Wallet" },
{ "key": "mining", "label": "Mining" },
{ "key": "dashboards", "label": "Dashboards" },
{ "key": "settings", "label": "Settings" }

View File

@@ -51,6 +51,7 @@ CREATE TABLE IF NOT EXISTS miningcheck_measurements (
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,
@@ -84,6 +85,28 @@ CREATE TABLE IF NOT EXISTS miningcheck_targets (
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,

View File

@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS miningcheck_measurements (
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,
@@ -103,6 +104,28 @@ CREATE TABLE IF NOT EXISTS miningcheck_payouts (
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,

View File

@@ -58,6 +58,7 @@ CREATE TABLE IF NOT EXISTS miningcheck_measurements (
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,
@@ -112,6 +113,30 @@ CREATE TABLE IF NOT EXISTS miningcheck_payouts (
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,

View File

@@ -55,6 +55,7 @@ CREATE TABLE IF NOT EXISTS miningcheck_measurements (
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,
@@ -103,6 +104,28 @@ CREATE TABLE IF NOT EXISTS miningcheck_payouts (
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,

View File

@@ -253,6 +253,15 @@ final class Router
Http::json(['data' => $this->savePayout($projectKey, Http::input())], 201);
}
if ($resource === 'wallet-snapshots' && $method === 'GET') {
Http::json(['data' => $this->walletSnapshots($projectKey)]);
}
if ($resource === 'wallet-snapshots' && $method === 'POST') {
$this->repository()->ensureProject($projectKey);
Http::json(['data' => $this->saveWalletSnapshot($projectKey, Http::input())], 201);
}
if ($resource === 'miner-offers' && $method === 'GET') {
Http::json(['data' => $this->minerOffers($projectKey)]);
}
@@ -329,6 +338,10 @@ final class Router
'purchased_miners' => [],
'measurement_rates' => [],
], ['project_key' => $projectKey]);
$walletSnapshots = $this->safeTimed('bootstrap.wallet_snapshots', fn () => $this->bootstrapWalletSnapshots($projectKey, $view), [], [
'project_key' => $projectKey,
'view' => $view,
]);
$measurements = $this->safeTimed('bootstrap.measurements', fn () => $this->bootstrapMeasurements($projectKey, $settings, $view), [], [
'project_key' => $projectKey,
'view' => $view,
@@ -369,6 +382,7 @@ final class Router
return [
'project' => $this->repository()->getProject($projectKey),
'settings' => $settings,
'wallet_snapshots' => $walletSnapshots,
'measurements' => $measurements,
'targets' => $targets,
'dashboards' => $dashboards,
@@ -483,6 +497,7 @@ final class Router
'measurements' => $this->safeRead(fn () => $this->repository()->listAllMeasurements($projectKey), []),
'measurement_rates' => $this->safeRead(fn () => $this->repository()->listMeasurementRates($projectKey), []),
'payouts' => $this->safeRead(fn () => $this->repository()->listPayouts($projectKey), []),
'wallet_snapshots' => $this->safeRead(fn () => $this->repository()->listWalletSnapshots($projectKey, 500), []),
'targets' => $this->safeRead(fn () => $this->repository()->listTargets($projectKey), []),
'dashboards' => $this->safeRead(fn () => $this->repository()->listDashboards($projectKey), []),
'miner_offers' => $this->safeRead(fn () => $this->repository()->listMinerOffers($projectKey), []),
@@ -587,6 +602,23 @@ final class Router
]);
}
foreach ($backup['wallet_snapshots'] as $snapshot) {
$this->repository()->saveWalletSnapshot($projectKey, [
'measured_at' => $snapshot['measured_at'],
'total_value_amount' => $snapshot['total_value_amount'] ?? null,
'total_value_currency' => $snapshot['total_value_currency'] ?? null,
'wallet_balance' => $snapshot['wallet_balance'] ?? null,
'wallet_currency' => $snapshot['wallet_currency'] ?? ($backup['settings']['crypto_currency'] ?? 'DOGE'),
'balances_json' => $snapshot['balances_json'] ?? [],
'note' => $snapshot['note'] ?? null,
'source' => $snapshot['source'] ?? 'manual',
'image_path' => $snapshot['image_path'] ?? null,
'ocr_raw_text' => $snapshot['ocr_raw_text'] ?? null,
'ocr_confidence' => $snapshot['ocr_confidence'] ?? null,
'ocr_flags' => $snapshot['ocr_flags'] ?? [],
]);
}
$minerOfferIdMap = [];
foreach ($backup['miner_offers'] as $offer) {
$savedOffer = $this->repository()->saveMinerOffer($projectKey, [
@@ -694,6 +726,7 @@ final class Router
'purchased_miners' => count($backup['purchased_miners']),
'cost_plans' => count($backup['cost_plans']),
'payouts' => count($backup['payouts']),
'wallet_snapshots' => count($backup['wallet_snapshots']),
'targets' => count($backup['targets']),
'dashboards' => count($backup['dashboards']),
'miner_offers' => count($backup['miner_offers']),
@@ -1191,7 +1224,7 @@ final class Router
private function bootstrapMeasurements(string $projectKey, array $settings, string $view): array
{
if (in_array($view, ['settings', 'dashboards'], true)) {
if (in_array($view, ['settings', 'dashboards', 'wallet'], true)) {
return [];
}
@@ -1212,6 +1245,15 @@ final class Router
return $this->analytics()->enrichMeasurements($rows, $settings);
}
private function bootstrapWalletSnapshots(string $projectKey, string $view): array
{
if (!in_array($view, ['wallet'], true)) {
return [];
}
return $this->repository()->listWalletSnapshots($projectKey, 50);
}
private function bootstrapTargets(string $projectKey, string $view): array
{
return in_array($view, ['overview', 'mining'], true) ? $this->targets($projectKey) : [];
@@ -1285,6 +1327,7 @@ final class Router
? $this->requiredDateTime($input['measured_at'] ?? null, 'measured_at', $projectTimezone)
: $this->currentTimestamp(),
'coins_total' => $this->requiredDecimal($input['coins_total'] ?? null, 'coins_total'),
'coin_currency' => $this->measurementCoinCurrency($projectKey, $input),
'price_per_coin' => $this->optionalDecimal($input['price_per_coin'] ?? null),
'price_currency' => $this->optionalCurrency($input['price_currency'] ?? null),
'note' => $this->optionalString($input['note'] ?? null, 2000),
@@ -1330,7 +1373,7 @@ final class Router
}
try {
$payload = $this->parseImportLine($trimmed, $defaultCurrency, $defaultSource, $this->projectTimezone($projectKey));
$payload = $this->parseImportLine($projectKey, $trimmed, $defaultCurrency, $defaultSource, $this->projectTimezone($projectKey));
$this->syncCurrencyCatalogForMeasurement($payload);
$payload['fx_fetch_id'] = $this->resolveMeasurementFxFetchId($projectKey, $payload, false);
$result = $this->repository()->createMeasurementIfNotExists($projectKey, $payload);
@@ -1368,6 +1411,15 @@ final class Router
private function syncCurrencyCatalogForMeasurement(array $payload): void
{
$coinCurrency = strtoupper(trim((string) ($payload['coin_currency'] ?? '')));
if ($coinCurrency !== '' && $this->currencyCatalogEntry($coinCurrency) === null) {
throw new ApiException(
'Coin-Waehrung ist im fx-rates Katalog nicht vorhanden.',
422,
['currency' => $coinCurrency]
);
}
$priceCurrency = strtoupper(trim((string) ($payload['price_currency'] ?? '')));
if ($priceCurrency === '') {
return;
@@ -1382,7 +1434,7 @@ final class Router
}
}
private function parseImportLine(string $line, ?string $defaultCurrency, string $defaultSource, string $projectTimezone): array
private function parseImportLine(string $projectKey, string $line, ?string $defaultCurrency, string $defaultSource, string $projectTimezone): array
{
$parts = array_map('trim', explode('|', $line));
if (count($parts) < 2) {
@@ -1402,6 +1454,7 @@ final class Router
return [
'measured_at' => $measuredAt,
'coins_total' => $coinsTotal,
'coin_currency' => $this->measurementCoinCurrency($projectKey),
'price_per_coin' => $pricePerCoin,
'price_currency' => $priceCurrency,
'note' => $note,
@@ -1629,6 +1682,36 @@ final class Router
return $this->repository()->savePayout($projectKey, $payload);
}
private function walletSnapshots(string $projectKey): array
{
return $this->repository()->listWalletSnapshots($projectKey, 100);
}
private function saveWalletSnapshot(string $projectKey, array $input): array
{
$balances = $input['balances_json'] ?? [];
if (!is_array($balances)) {
$balances = [];
}
$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'),
'balances_json' => $balances,
'note' => $this->optionalString($input['note'] ?? null, 1000),
'source' => $this->enumValue($input['source'] ?? 'manual', ['manual', 'image_ocr', 'seed_import'], 'source'),
'image_path' => $this->optionalString($input['image_path'] ?? null, 255),
'ocr_raw_text' => $this->optionalString($input['ocr_raw_text'] ?? null, 20000),
'ocr_confidence' => $this->optionalDecimal($input['ocr_confidence'] ?? null),
'ocr_flags' => is_array($input['ocr_flags'] ?? null) ? $input['ocr_flags'] : [],
];
return $this->repository()->saveWalletSnapshot($projectKey, $payload);
}
private function saveMinerOffer(string $projectKey, array $input): array
{
$payload = [
@@ -1658,9 +1741,16 @@ final class Router
throw new ApiException('Miner-Angebot nicht gefunden.', 404);
}
$settings = $this->settings($projectKey);
$cryptoCurrency = $this->requiredCurrency($settings['crypto_currency'] ?? 'DOGE', 'crypto_currency');
$isAutoRenew = array_key_exists('auto_renew', $input) ? !empty($input['auto_renew']) : !empty($offer['auto_renew']);
$purchaseCurrency = $this->optionalCurrency($input['currency'] ?? null) ?? (string) ($offer['effective_price_currency'] ?? $offer['price_currency'] ?? $offer['base_price_currency'] ?? '');
if (!$isAutoRenew) {
$purchaseCurrency = $cryptoCurrency;
}
$purchaseCost = $this->optionalDecimal($input['total_cost_amount'] ?? null);
if ($purchaseCost === null) {
if ($purchaseCost === null || !$isAutoRenew) {
$purchaseCost = $this->resolveOfferPurchaseCost(array_merge($offer, [
'price_currency' => $purchaseCurrency !== '' ? $purchaseCurrency : ($offer['price_currency'] ?? ''),
]));
@@ -1684,7 +1774,7 @@ final class Router
'usd_reference_amount' => $offer['usd_reference_amount'] ?? null,
'reference_price_amount' => $referencePriceAmount,
'reference_price_currency' => $referencePriceCurrency,
'auto_renew' => array_key_exists('auto_renew', $input) ? (!empty($input['auto_renew']) ? 1 : 0) : (!empty($offer['auto_renew']) ? 1 : 0),
'auto_renew' => $isAutoRenew ? 1 : 0,
'note' => $this->optionalString($input['note'] ?? ($offer['note'] ?? null), 1000),
'is_active' => 1,
]);
@@ -2171,6 +2261,17 @@ final class Router
return null;
}
private function measurementCoinCurrency(string $projectKey, array $input = []): string
{
$requested = $this->optionalCurrency($input['coin_currency'] ?? null);
if ($requested !== null) {
return $requested;
}
$settings = $this->settings($projectKey);
return $this->requiredCurrency($settings['crypto_currency'] ?? 'DOGE', 'crypto_currency');
}
private function ensureMeasurementFxReferences(string $projectKey, array $rows, ?array $settings = null): array
{
$maxAgeHours = self::FX_FETCH_MAX_AGE_HOURS;

View File

@@ -34,23 +34,26 @@ final class AnalyticsService
$previousIntervalRate = null;
$result = [];
$payoutIndex = 0;
$cumulativePayouts = 0.0;
$lastPayoutTs = null;
$payoutsByAsset = [];
$latestPriceByCurrency = [];
foreach ($measurements as $row) {
$measuredTs = $this->utcTimestamp((string) $row['measured_at']);
$coinCurrency = strtoupper(trim((string) ($row['coin_currency'] ?? $settings['crypto_currency'] ?? 'DOGE')));
while (isset($payouts[$payoutIndex])) {
$payoutTs = $this->utcTimestamp((string) ($payouts[$payoutIndex]['payout_at'] ?? ''));
if ($payoutTs <= 0 || $payoutTs > $measuredTs) {
break;
}
$cumulativePayouts += (float) ($payouts[$payoutIndex]['coins_amount'] ?? 0);
$payoutAsset = strtoupper(trim((string) ($payouts[$payoutIndex]['payout_currency'] ?? $coinCurrency)));
$payoutsByAsset[$payoutAsset] = ($payoutsByAsset[$payoutAsset] ?? 0.0) + (float) ($payouts[$payoutIndex]['coins_amount'] ?? 0);
$lastPayoutTs = $payoutTs;
$payoutIndex++;
}
$cumulativePayouts = (float) ($payoutsByAsset[$coinCurrency] ?? 0.0);
$visibleCoinsTotal = (float) $row['coins_total'];
$effectiveCoinsTotal = $visibleCoinsTotal + $cumulativePayouts;
$growth = $effectiveCoinsTotal - $baselineCoins;
@@ -120,7 +123,7 @@ final class AnalyticsService
}
if ($price === null) {
foreach (['USD', 'EUR'] as $fallbackCurrency) {
$fxPrice = $this->convertAmount(1.0, 'DOGE', $fallbackCurrency, $row);
$fxPrice = $this->convertAmount(1.0, $coinCurrency, $fallbackCurrency, $row);
if ($fxPrice !== null && $fxPrice > 0) {
$latestPriceByCurrency[$fallbackCurrency] = $fxPrice;
}
@@ -159,6 +162,7 @@ final class AnalyticsService
$result[] = array_merge($row, [
'coins_total_visible' => $this->roundOrNull($visibleCoinsTotal, 6),
'coins_total_effective' => $this->roundOrNull($effectiveCoinsTotal, 6),
'coin_currency' => $coinCurrency,
'payouts_cumulative' => $this->roundOrNull($cumulativePayouts, 6),
'growth_since_baseline' => $this->roundOrNull($growth, 6),
'hours_since_baseline' => $this->roundOrNull($hoursSinceBaseline, 4),
@@ -296,26 +300,60 @@ final class AnalyticsService
}
$latestCurrency = (string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? '');
$investedCapital = $latestCurrency !== ''
$latestMeasuredTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? ''));
$cashInvestedCapital = $latestCurrency !== ''
? $this->totalInvestmentBasis(
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
$purchasedMiners,
$this->utcTimestamp((string) ($latest['measured_at'] ?? '')),
$latestMeasuredTs,
$latestCurrency,
$latest
$latest,
'cash'
)
: null;
$reinvestedCapital = $latestCurrency !== ''
? $this->totalInvestmentBasis(
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
$purchasedMiners,
$latestMeasuredTs,
$latestCurrency,
$latest,
'reinvest'
)
: null;
$walletBalances = $this->walletBalances($payouts, $purchasedMiners, $latestMeasuredTs);
$walletBalanceCurrentAsset = (float) ($walletBalances[strtoupper(trim((string) ($latest['coin_currency'] ?? $settings['crypto_currency'] ?? 'DOGE')))] ?? 0.0);
$holdingsCurrentAsset = $walletBalanceCurrentAsset + (float) ($latest['coins_total_visible'] ?? $latest['coins_total'] ?? 0);
$walletValue = $latestCurrency !== '' ? $this->walletBalanceValue($walletBalances, $latestCurrency, $latest) : null;
$currentVisibleValue = is_numeric($latest['current_value'] ?? null) ? (float) $latest['current_value'] : null;
$totalHoldingsValue = ($walletValue !== null || $currentVisibleValue !== null)
? (float) ($walletValue ?? 0.0) + (float) ($currentVisibleValue ?? 0.0)
: null;
$currentDailyRevenue = is_numeric($latest['theoretical_daily_revenue'] ?? null) ? (float) $latest['theoretical_daily_revenue'] : null;
$breakEvenRemainingAmount = $investedCapital;
$breakEvenDaysOverall = (
$investedCapital !== null &&
$currentDailyRevenue !== null &&
$currentDailyRevenue > 0
) ? ($investedCapital / $currentDailyRevenue) : null;
$breakEvenRemainingAmount = ($cashInvestedCapital !== null && $totalHoldingsValue !== null)
? max(0.0, $cashInvestedCapital - $totalHoldingsValue)
: $cashInvestedCapital;
$breakEvenProjection = (
$cashInvestedCapital !== null &&
$breakEvenRemainingAmount !== null
) ? $this->projectBreakEvenDate(
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
$purchasedMiners,
$latest,
$breakEvenRemainingAmount
) : ['days' => null, 'eta' => null];
$breakEvenDaysOverall = is_numeric($breakEvenProjection['days'] ?? null) ? (float) $breakEvenProjection['days'] : null;
$latestSummary = array_merge($latest, [
'invested_capital' => $this->roundOrNull($investedCapital, 8),
'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),
'holdings_current_asset' => $this->roundOrNull($holdingsCurrentAsset, 6),
'wallet_value' => $this->roundOrNull($walletValue, 8),
'total_holdings_value' => $this->roundOrNull($totalHoldingsValue, 8),
'break_even_remaining_amount' => $this->roundOrNull($breakEvenRemainingAmount, 8),
'break_even_days_overall' => $this->roundOrNull($breakEvenDaysOverall, 4),
'break_even_eta_at' => $breakEvenProjection['eta'] ?? null,
]);
$currentProjection = $this->projectPerformance(
is_array($settings['cost_plans'] ?? null) ? $settings['cost_plans'] : [],
@@ -349,6 +387,9 @@ final class AnalyticsService
'total_coins' => $this->roundOrNull(array_sum(array_map(static fn (array $payout): float => (float) ($payout['coins_amount'] ?? 0), $payouts)), 6),
'current_visible_coins' => $this->roundOrNull((float) ($latest['coins_total_visible'] ?? $latest['coins_total']), 6),
'current_effective_coins' => $this->roundOrNull((float) ($latest['coins_total_effective'] ?? $latest['coins_total']), 6),
'wallet_balances' => $walletBalances,
'wallet_balance_current_asset' => $this->roundOrNull($walletBalanceCurrentAsset, 6),
'holdings_current_asset' => $this->roundOrNull($holdingsCurrentAsset, 6),
],
'current_hashrate_mh' => $this->roundOrNull($currentHashrateMh, 4),
'miner_offers' => $offerSummary,
@@ -628,7 +669,7 @@ final class AnalyticsService
if (array_key_exists('is_active', $miner) && empty($miner['is_active'])) {
continue;
}
if (!$this->entryIsCovered($miner, $measurementTs > 0 ? $measurementTs : null, 'purchased_at')) {
if ($measurementTs > 0 && $this->utcTimestamp((string) ($miner['purchased_at'] ?? '')) > $measurementTs) {
continue;
}
@@ -650,7 +691,7 @@ final class AnalyticsService
return $matched ? $total : null;
}
private function totalInvestmentBasis(array $costPlans, array $purchasedMiners, int $measurementTs, string $targetCurrency, ?array $fxContext = null): ?float
private function totalInvestmentBasis(array $costPlans, array $purchasedMiners, int $measurementTs, string $targetCurrency, ?array $fxContext = null, ?string $fundingSource = null): ?float
{
$target = strtoupper(trim($targetCurrency));
if ($target === '') {
@@ -661,26 +702,16 @@ final class AnalyticsService
$matched = false;
$purchasedTotal = $this->totalPurchasedMinerCost($purchasedMiners, $target, $measurementTs, $fxContext);
if ($purchasedTotal !== null) {
if ($fundingSource === null && $purchasedTotal !== null) {
$matched = true;
$total += $purchasedTotal;
}
foreach ($costPlans as $plan) {
if (empty($plan['is_active'])) {
if ($measurementTs > 0 && $this->utcTimestamp((string) ($plan['starts_at'] ?? '')) > $measurementTs) {
continue;
}
$startTs = $this->utcTimestamp((string) ($plan['starts_at'] ?? ''));
$runtimeMonths = (int) ($plan['runtime_months'] ?? 0);
if ($startTs <= 0 || $runtimeMonths <= 0 || ($measurementTs > 0 && $measurementTs < $startTs)) {
continue;
}
$runtimeDays = $runtimeMonths * 30.4375;
$endTs = (int) round($startTs + ($runtimeDays * 86400));
$isCovered = !empty($plan['auto_renew']) || $measurementTs <= 0 || $measurementTs <= $endTs;
if (!$isCovered) {
if ($fundingSource !== null && $this->entryFundingSource($plan) !== $fundingSource) {
continue;
}
@@ -699,6 +730,31 @@ final class AnalyticsService
$total += $converted;
}
if ($fundingSource !== null) {
foreach ($purchasedMiners as $miner) {
if ($measurementTs > 0 && $this->utcTimestamp((string) ($miner['purchased_at'] ?? '')) > $measurementTs) {
continue;
}
if ($this->entryFundingSource($miner) !== $fundingSource) {
continue;
}
$amount = $this->investmentBasisAmount($miner);
$currency = strtoupper(trim((string) ($miner['reference_price_currency'] ?? $miner['currency'] ?? '')));
if ($amount === null || $amount <= 0 || $currency === '') {
continue;
}
$converted = $this->convertAmount($amount, $currency, $target, $fxContext);
if ($converted === null) {
continue;
}
$matched = true;
$total += $converted;
}
}
return $matched ? $total : null;
}
@@ -1014,6 +1070,153 @@ final class AnalyticsService
return $endIndex - $startIndex + 1;
}
private function walletBalances(array $payouts, array $purchasedMiners, int $measurementTs): array
{
$balances = [];
foreach ($payouts as $payout) {
$payoutTs = $this->utcTimestamp((string) ($payout['payout_at'] ?? ''));
if ($measurementTs > 0 && $payoutTs > $measurementTs) {
continue;
}
$currency = strtoupper(trim((string) ($payout['payout_currency'] ?? '')));
$amount = is_numeric($payout['coins_amount'] ?? null) ? (float) $payout['coins_amount'] : null;
if ($currency === '' || $amount === null) {
continue;
}
$balances[$currency] = ($balances[$currency] ?? 0.0) + $amount;
}
foreach ($purchasedMiners as $miner) {
$purchaseTs = $this->utcTimestamp((string) ($miner['purchased_at'] ?? ''));
if ($measurementTs > 0 && $purchaseTs > $measurementTs) {
continue;
}
if ($this->entryFundingSource($miner) !== 'reinvest') {
continue;
}
$currency = strtoupper(trim((string) ($miner['currency'] ?? '')));
$amount = is_numeric($miner['total_cost_amount'] ?? null) ? (float) $miner['total_cost_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);
}
private function walletBalanceValue(array $balances, string $targetCurrency, ?array $fxContext = null): ?float
{
$target = strtoupper(trim($targetCurrency));
if ($target === '' || $balances === []) {
return null;
}
$total = 0.0;
$matched = false;
foreach ($balances as $currency => $amount) {
if (!is_numeric($amount)) {
continue;
}
$numericAmount = (float) $amount;
if (strtoupper((string) $currency) === $target) {
$matched = true;
$total += $numericAmount;
continue;
}
$converted = $this->convertAmount($numericAmount, (string) $currency, $target, $fxContext);
if ($converted === null) {
continue;
}
$matched = true;
$total += $converted;
}
return $matched ? $total : null;
}
private function entryFundingSource(array $entry): string
{
return !empty($entry['auto_renew']) ? 'cash' : 'reinvest';
}
private function investmentBasisAmount(array $entry): ?float
{
if (is_numeric($entry['reference_price_amount'] ?? null)) {
return (float) $entry['reference_price_amount'];
}
if (is_numeric($entry['base_price_amount'] ?? null)) {
return (float) $entry['base_price_amount'];
}
if (is_numeric($entry['total_cost_amount'] ?? null)) {
return (float) $entry['total_cost_amount'];
}
return null;
}
private function projectBreakEvenDate(array $costPlans, array $purchasedMiners, array $latest, float $remainingAmount): array
{
if ($remainingAmount <= 0) {
return ['days' => 0.0, 'eta' => (string) ($latest['measured_at'] ?? null)];
}
$currency = strtoupper(trim((string) ($latest['effective_price_currency'] ?? $latest['price_currency'] ?? '')));
$pricePerCoin = is_numeric($latest['effective_price_per_coin'] ?? null) ? (float) $latest['effective_price_per_coin'] : null;
$dogePerDay = is_numeric($latest['doge_per_day_interval'] ?? null) ? (float) $latest['doge_per_day_interval'] : null;
$currentHashrateMh = $this->totalHashrateMh(array_merge($costPlans, $purchasedMiners));
$baseTs = $this->utcTimestamp((string) ($latest['measured_at'] ?? ''));
if ($currency === '' || $pricePerCoin === null || $dogePerDay === null || $currentHashrateMh <= 0 || $baseTs <= 0) {
return ['days' => null, 'eta' => null];
}
$dogePerDayPerMh = $dogePerDay / $currentHashrateMh;
$cumulativeRevenue = 0.0;
$maxDays = 3650;
for ($day = 0; $day <= $maxDays; $day++) {
$dayHashrate = 0.0;
$dayTs = $baseTs + ($day * 86400);
foreach ($costPlans as $plan) {
if (!empty($plan['is_active']) && $this->entryIsCovered($plan, $dayTs)) {
$dayHashrate += $this->normalizeHashrateMh($plan['mining_speed_value'] ?? null, $plan['mining_speed_unit'] ?? null);
$dayHashrate += $this->normalizeHashrateMh($plan['bonus_speed_value'] ?? null, $plan['bonus_speed_unit'] ?? null);
}
}
foreach ($purchasedMiners as $miner) {
if ((array_key_exists('is_active', $miner) && empty($miner['is_active'])) || !$this->entryIsCovered($miner, $dayTs, 'purchased_at')) {
continue;
}
$dayHashrate += $this->normalizeHashrateMh($miner['mining_speed_value'] ?? null, $miner['mining_speed_unit'] ?? null);
$dayHashrate += $this->normalizeHashrateMh($miner['bonus_speed_value'] ?? null, $miner['bonus_speed_unit'] ?? null);
}
if ($dayHashrate <= 0) {
continue;
}
$dayRevenue = $dayHashrate * $dogePerDayPerMh * $pricePerCoin;
$cumulativeRevenue += $dayRevenue;
if ($cumulativeRevenue >= $remainingAmount) {
$etaTs = (int) round($baseTs + ($day * 86400));
return [
'days' => (float) $day,
'eta' => $this->formatUtcTimestamp($etaTs),
];
}
}
return ['days' => null, 'eta' => null];
}
private function utcTimestamp(?string $value): int
{
$normalized = trim((string) $value);

View File

@@ -46,7 +46,11 @@ final class OcrService
$flags[] = 'ocr_hint_text_used';
}
$parsed = $this->parseText($rawText, (string) ($input['date_context'] ?? date('Y-m-d')));
$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'])));
@@ -291,7 +295,27 @@ final class OcrService
return $binary !== '' && trim((string) shell_exec('command -v ' . escapeshellarg($binary) . ' 2>/dev/null')) !== '';
}
private function parseText(string $rawText, string $dateContext): array
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;
@@ -433,6 +457,107 @@ final class OcrService
],
'confidence' => round($confidence, 4),
'flags' => $flags,
'score' => $matchedFields,
];
}
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';
}
preg_match_all('/(\d+(?:[.,]\d+)?)\s*([A-Z]{2,10})\b/u', $normalizedText, $balanceMatches, PREG_SET_ORDER);
foreach ($balanceMatches as $match) {
$amount = round((float) str_replace(',', '.', $match[1]), 10);
$currency = strtoupper((string) $match[2]);
if ($amount <= 0 || $currency === '') {
continue;
}
if (!isset($balances[$currency]) || $amount > (float) $balances[$currency]) {
$balances[$currency] = $amount;
}
}
if ($walletCurrencyHint !== '' && array_key_exists($walletCurrencyHint, $balances)) {
$walletCurrency = $walletCurrencyHint;
$walletBalance = (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 = (float) $balances[$preferredCurrency];
break;
}
}
if ($walletBalance === null) {
$firstCurrency = array_key_first($balances);
if (is_string($firstCurrency)) {
$walletCurrency = $firstCurrency;
$walletBalance = (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,
];
}
}

View File

@@ -229,6 +229,7 @@ final class MiningRepository
'owner_sub' => $this->ownerSub,
'measured_at' => $payload['measured_at'],
'coins_total' => $payload['coins_total'],
'coin_currency' => $payload['coin_currency'] ?? 'DOGE',
'price_per_coin' => $payload['price_per_coin'],
'price_currency' => $payload['price_currency'],
'fx_fetch_id' => $payload['fx_fetch_id'] ?? null,
@@ -243,10 +244,10 @@ final class MiningRepository
if ($this->driver === 'pgsql') {
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('measurements') . ' (
project_key, owner_sub, measured_at, coins_total, price_per_coin, price_currency, fx_fetch_id, note,
project_key, owner_sub, measured_at, coins_total, coin_currency, price_per_coin, price_currency, fx_fetch_id, note,
source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
) VALUES (
:project_key, :owner_sub, :measured_at, :coins_total, :price_per_coin, :price_currency, :fx_fetch_id, :note,
:project_key, :owner_sub, :measured_at, :coins_total, :coin_currency, :price_per_coin, :price_currency, :fx_fetch_id, :note,
:source, :image_path, :ocr_raw_text, :ocr_confidence, CAST(:ocr_flags AS jsonb)
)
RETURNING *'
@@ -259,10 +260,10 @@ final class MiningRepository
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('measurements') . ' (
project_key, owner_sub, measured_at, coins_total, price_per_coin, price_currency, fx_fetch_id, note,
project_key, owner_sub, measured_at, coins_total, coin_currency, price_per_coin, price_currency, fx_fetch_id, note,
source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
) VALUES (
:project_key, :owner_sub, :measured_at, :coins_total, :price_per_coin, :price_currency, :fx_fetch_id, :note,
:project_key, :owner_sub, :measured_at, :coins_total, :coin_currency, :price_per_coin, :price_currency, :fx_fetch_id, :note,
:source, :image_path, :ocr_raw_text, :ocr_confidence, :ocr_flags
)'
);
@@ -441,6 +442,71 @@ final class MiningRepository
return $this->normalizeRow($fetch->fetch() ?: []);
}
public function listWalletSnapshots(string $projectKey, int $limit = 100): array
{
$stmt = $this->pdo->prepare(
'SELECT * FROM ' . $this->table('wallet_snapshots') . '
WHERE project_key = :project_key AND owner_sub = :owner_sub
ORDER BY measured_at DESC, id DESC
LIMIT :limit'
);
$stmt->bindValue(':project_key', $projectKey, PDO::PARAM_STR);
$stmt->bindValue(':owner_sub', $this->ownerSub, PDO::PARAM_STR);
$stmt->bindValue(':limit', $limit, PDO::PARAM_INT);
$stmt->execute();
return $this->normalizeRows($stmt->fetchAll() ?: []);
}
public function saveWalletSnapshot(string $projectKey, array $payload): array
{
$params = [
'project_key' => $projectKey,
'owner_sub' => $this->ownerSub,
'measured_at' => $payload['measured_at'],
'total_value_amount' => $payload['total_value_amount'] ?? null,
'total_value_currency' => $payload['total_value_currency'] ?? null,
'wallet_balance' => $payload['wallet_balance'] ?? null,
'wallet_currency' => $payload['wallet_currency'],
'balances_json' => json_encode($payload['balances_json'] ?? [], JSON_UNESCAPED_UNICODE),
'note' => $payload['note'] ?? null,
'source' => $payload['source'] ?? 'manual',
'image_path' => $payload['image_path'] ?? null,
'ocr_raw_text' => $payload['ocr_raw_text'] ?? null,
'ocr_confidence' => $payload['ocr_confidence'] ?? null,
'ocr_flags' => json_encode($payload['ocr_flags'] ?? [], JSON_UNESCAPED_UNICODE),
];
if ($this->driver === 'pgsql') {
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('wallet_snapshots') . ' (
project_key, owner_sub, measured_at, total_value_amount, total_value_currency, wallet_balance,
wallet_currency, balances_json, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
) VALUES (
:project_key, :owner_sub, :measured_at, :total_value_amount, :total_value_currency, :wallet_balance,
:wallet_currency, CAST(:balances_json AS jsonb), :note, :source, :image_path, :ocr_raw_text, :ocr_confidence, CAST(:ocr_flags AS jsonb)
)
RETURNING *'
);
$stmt->execute($params);
return $this->normalizeRow($stmt->fetch() ?: []);
}
$stmt = $this->pdo->prepare(
'INSERT INTO ' . $this->table('wallet_snapshots') . ' (
project_key, owner_sub, measured_at, total_value_amount, total_value_currency, wallet_balance,
wallet_currency, balances_json, note, source, image_path, ocr_raw_text, ocr_confidence, ocr_flags
) VALUES (
:project_key, :owner_sub, :measured_at, :total_value_amount, :total_value_currency, :wallet_balance,
:wallet_currency, :balances_json, :note, :source, :image_path, :ocr_raw_text, :ocr_confidence, :ocr_flags
)'
);
$stmt->execute($params);
$id = (int) $this->pdo->lastInsertId();
$fetch = $this->pdo->prepare('SELECT * FROM ' . $this->table('wallet_snapshots') . ' WHERE id = :id LIMIT 1');
$fetch->execute(['id' => $id]);
return $this->normalizeRow($fetch->fetch() ?: []);
}
public function listTargets(string $projectKey): array
{
$stmt = $this->pdo->prepare(
@@ -1195,6 +1261,7 @@ final class MiningRepository
'fx_rates' => $this->prefix . 'fx_rates',
'measurement_rates' => $this->prefix . 'measurement_rates',
'payouts' => $this->prefix . 'payouts',
'wallet_snapshots' => $this->prefix . 'wallet_snapshots',
'miner_offers' => $this->prefix . 'miner_offers',
'purchased_miners' => $this->prefix . 'purchased_miners',
default => throw new \RuntimeException('Unknown mining table: ' . $logicalName),
@@ -1273,7 +1340,7 @@ final class MiningRepository
private function normalizeRow(array $row): array
{
foreach (['ocr_flags', 'filters_json', 'preferred_currencies'] as $jsonField) {
foreach (['ocr_flags', 'filters_json', 'preferred_currencies', 'balances_json'] as $jsonField) {
if (array_key_exists($jsonField, $row) && is_string($row[$jsonField]) && trim($row[$jsonField]) !== '') {
$decoded = json_decode($row[$jsonField], true);
if (json_last_error() === JSON_ERROR_NONE) {

View File

@@ -188,6 +188,10 @@ final class SchemaManager
$this->ensureMeasurementFxReferenceColumn();
$applied[] = 'measurement_fx_reference';
}
if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'coin_currency')) {
$this->ensureMeasurementCoinCurrencyColumn();
$applied[] = 'measurement_coin_currency';
}
if (!$this->tableExists($this->prefix . 'measurement_rates')) {
$this->ensureMeasurementRatesTable();
@@ -197,6 +201,10 @@ final class SchemaManager
$this->ensurePayoutsTable();
$applied[] = 'payouts_table';
}
if (!$this->tableExists($this->prefix . 'wallet_snapshots')) {
$this->ensureWalletSnapshotsTable();
$applied[] = 'wallet_snapshots_table';
}
if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) {
$this->ensureFxRatesTable();
$applied[] = 'fx_rates_table';
@@ -293,6 +301,10 @@ final class SchemaManager
$this->ensureMeasurementFxReferenceColumn();
$applied[] = 'measurement_fx_reference';
}
if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'coin_currency')) {
$this->ensureMeasurementCoinCurrencyColumn();
$applied[] = 'measurement_coin_currency';
}
if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) {
$this->ensureFxRatesTable();
@@ -308,6 +320,10 @@ final class SchemaManager
$this->ensurePayoutsTable();
$applied[] = 'payouts_table';
}
if (!$this->tableExists($this->prefix . 'wallet_snapshots')) {
$this->ensureWalletSnapshotsTable();
$applied[] = 'wallet_snapshots_table';
}
if (!$this->tableExists($this->prefix . 'miner_offers')) {
$this->upgradeMinerOffersTable();
@@ -421,6 +437,54 @@ final class SchemaManager
}
}
private function ensureMeasurementCoinCurrencyColumn(): void
{
$table = $this->prefix . 'measurements';
$settingsTable = $this->prefix . 'settings';
if (!$this->tableExists($table)) {
return;
}
$statements = $this->driver === 'pgsql'
? [
'ALTER TABLE ' . $table . ' ADD COLUMN IF NOT EXISTS coin_currency VARCHAR(10)',
'UPDATE ' . $table . ' AS m
SET coin_currency = COALESCE(NULLIF(BTRIM(s.crypto_currency), \'\'), \'DOGE\')
FROM ' . $settingsTable . ' AS s
WHERE s.project_key = m.project_key
AND (m.coin_currency IS NULL OR BTRIM(m.coin_currency) = \'\')',
'UPDATE ' . $table . ' SET coin_currency = \'DOGE\' WHERE coin_currency IS NULL OR BTRIM(coin_currency) = \'\'',
'ALTER TABLE ' . $table . ' ALTER COLUMN coin_currency SET DEFAULT \'DOGE\'',
'ALTER TABLE ' . $table . ' ALTER COLUMN coin_currency SET NOT NULL',
]
: [
'ALTER TABLE `' . $table . '` ADD COLUMN coin_currency VARCHAR(10) NOT NULL DEFAULT \'DOGE\' AFTER coins_total',
'UPDATE `' . $table . '` m
LEFT JOIN `' . $settingsTable . '` s ON s.project_key = m.project_key
SET m.coin_currency = COALESCE(NULLIF(TRIM(s.crypto_currency), \'\'), \'DOGE\')
WHERE m.coin_currency IS NULL OR TRIM(m.coin_currency) = \'\'',
];
foreach ($statements as $index => $statement) {
try {
if ($this->driver === 'mysql' && $index === 0 && $this->columnExists($table, 'coin_currency')) {
continue;
}
$this->executeUpgradeStatements([$statement], 'Messpunkt-Coin-Waehrung konnte nicht angelegt werden.');
} catch (\Throwable $exception) {
$message = strtolower($exception->getMessage());
if (
($this->driver === 'mysql' && $index === 0 && str_contains($message, 'duplicate column')) ||
($this->driver === 'pgsql' && str_contains($message, 'already exists'))
) {
continue;
}
throw $exception;
}
}
}
private function ensureLegacyMinerOfferImportColumns(): void
{
$table = $this->prefix . 'miner_offers';
@@ -599,6 +663,9 @@ final class SchemaManager
if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'fx_fetch_id')) {
$upgrades[] = 'measurement_fx_reference';
}
if ($this->tableExists($this->prefix . 'measurements') && !$this->columnExists($this->prefix . 'measurements', 'coin_currency')) {
$upgrades[] = 'measurement_coin_currency';
}
if (!$this->tableExists($this->prefix . 'fx_fetches') || !$this->tableExists($this->prefix . 'fx_rates')) {
$upgrades[] = 'fx_rates_table';
@@ -609,6 +676,9 @@ final class SchemaManager
if (!$this->tableExists($this->prefix . 'payouts')) {
$upgrades[] = 'payouts_table';
}
if (!$this->tableExists($this->prefix . 'wallet_snapshots')) {
$upgrades[] = 'wallet_snapshots_table';
}
if (!$this->tableExists($this->prefix . 'miner_offers')) {
$upgrades[] = 'miner_offers_table';
}
@@ -849,6 +919,65 @@ final class SchemaManager
$this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer Auszahlungen fehlgeschlagen.');
}
public function ensureWalletSnapshotsTable(): void
{
if ($this->tableExists($this->prefix . 'wallet_snapshots')) {
return;
}
$table = $this->prefix . 'wallet_snapshots';
$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,
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,
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 ' . $projectTable . '(project_key) ON DELETE CASCADE
)',
'CREATE INDEX IF NOT EXISTS idx_miningcheck_wallet_snapshots_project_measured_at ON ' . $table . ' (project_key, owner_sub, measured_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,
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 `' . $projectTable . '`(project_key) ON DELETE CASCADE,
KEY idx_miningcheck_wallet_snapshots_project_measured_at (project_key, owner_sub, measured_at)
)',
];
$this->executeUpgradeStatements($statements, 'Schema-Upgrade fuer Wallet-Snapshots fehlgeschlagen.');
}
public function ensureMinerTables(): void
{
if (!$this->tableExists($this->prefix . 'miner_offers')) {
@@ -1321,6 +1450,7 @@ final class SchemaManager
$this->prefix . 'fx_rates',
$this->prefix . 'measurement_rates',
$this->prefix . 'payouts',
$this->prefix . 'wallet_snapshots',
$this->prefix . 'miner_offers',
$this->prefix . 'purchased_miners',
];
@@ -1340,6 +1470,7 @@ final class SchemaManager
$this->prefix . 'measurements',
$this->prefix . 'measurement_rates',
$this->prefix . 'payouts',
$this->prefix . 'wallet_snapshots',
$this->prefix . 'miner_offers',
$this->prefix . 'targets',
$this->prefix . 'dashboard_definitions',