diff --git a/modules/mining-checker/assets/js/app.js b/modules/mining-checker/assets/js/app.js index ebf61ce..72ec550 100644 --- a/modules/mining-checker/assets/js/app.js +++ b/modules/mining-checker/assets/js/app.js @@ -805,8 +805,7 @@ runtime_months: 1, mining_speed_value: '', mining_speed_unit: 'MH/s', - bonus_speed_value: '', - bonus_speed_unit: 'MH/s', + bonus_percent: '', auto_renew: true, base_price_amount: '', payment_type: 'fiat', @@ -826,10 +825,7 @@ const [minerOfferForm, setMinerOfferForm] = useState({ label: '', runtime_months: '', - mining_speed_value: '', - mining_speed_unit: 'MH/s', - bonus_speed_value: '', - bonus_speed_unit: 'MH/s', + bonus_percent: '', base_price_amount: '', base_price_currency: 'USD', payment_type: 'fiat', @@ -841,9 +837,14 @@ const [purchaseMinerModalOpen, setPurchaseMinerModalOpen] = useState(false); const [purchaseMinerForm, setPurchaseMinerForm] = useState({ offer_id: '', + base_offer_id: '', purchased_at: '', + label: '', + mining_speed_value: '', + mining_speed_unit: '', + bonus_percent: '', total_cost_amount: '', - currency: 'USD', + currency: '', reference_price_amount: '', reference_price_currency: '', auto_renew: false, @@ -941,7 +942,7 @@ : cryptoCurrencies; const selectableFiatCurrencies = preferredSelectableFiatCurrencies.length ? preferredSelectableFiatCurrencies : fiatCurrencies; const selectableCryptoCurrencies = preferredSelectableCryptoCurrencies.length ? preferredSelectableCryptoCurrencies : cryptoCurrencies; - const selectedMinerScenario = availableMinerOffers.find((offer) => Number(offer.id) === Number(selectedMinerScenarioId)) || null; + const selectedMinerScenario = availableMinerOffers.find((offer) => String(offer.id) === String(selectedMinerScenarioId)) || null; const activeMinerRows = currentPurchasedMiners.map((miner) => ({ id: `purchase-${miner.id}`, source: 'miete', @@ -962,7 +963,7 @@ : 'fiat', is_active: miner.is_active !== false, can_toggle_auto_renew: Number(miner.runtime_months) > 0 && renewableOfferIds.has(Number(miner.miner_offer_id)), - hashrate_text: `${fmtNumber(((Number(miner.mining_speed_value) || 0) + (Number(miner.bonus_speed_value) || 0)), 4)} ${miner.mining_speed_unit || miner.bonus_speed_unit || ''}`.trim(), + hashrate_text: formatHashrateWithBonus(miner.mining_speed_value, miner.mining_speed_unit, miner.bonus_speed_value, miner.bonus_speed_unit), type_label: 'Aus Angebot gemietet', })).concat(currentCostPlans.map((plan) => ({ id: `plan-${plan.id}`, @@ -978,10 +979,7 @@ payment_type: plan.payment_type, is_active: !!plan.is_active, can_toggle_auto_renew: false, - hashrate_text: [ - formatSpeed(plan.mining_speed_value, plan.mining_speed_unit, 'Basis'), - formatSpeed(plan.bonus_speed_value, plan.bonus_speed_unit, 'Bonus'), - ].filter(Boolean).join(' · ') || 'n/a', + hashrate_text: formatHashrateWithBonus(plan.mining_speed_value, plan.mining_speed_unit, plan.bonus_speed_value, plan.bonus_speed_unit), type_label: 'Manuell eingetragen', }))).sort((left, right) => String(right.starts_at || '').localeCompare(String(left.starts_at || ''))); @@ -996,7 +994,7 @@ return; } - const exists = availableMinerOffers.some((offer) => Number(offer.id) === Number(selectedMinerScenarioId)); + const exists = availableMinerOffers.some((offer) => String(offer.id) === String(selectedMinerScenarioId)); if (!exists) { setSelectedMinerScenarioId(null); } @@ -1017,6 +1015,11 @@ setPurchaseMinerForm((current) => ({ ...current, offer_id: current.offer_id || String(selectedOffer.id), + base_offer_id: current.base_offer_id || String(selectedOffer.base_offer_id || selectedOffer.id || ''), + label: current.label || String(selectedOffer.label || ''), + mining_speed_value: current.mining_speed_value || String(selectedOffer.mining_speed_value || ''), + mining_speed_unit: current.mining_speed_unit || String(selectedOffer.mining_speed_unit || ''), + bonus_percent: current.bonus_percent || (selectedOffer.bonus_percent !== null && selectedOffer.bonus_percent !== undefined ? String(selectedOffer.bonus_percent) : ''), 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) : ''), @@ -1672,8 +1675,7 @@ runtime_months: 1, mining_speed_value: '', mining_speed_unit: 'MH/s', - bonus_speed_value: '', - bonus_speed_unit: 'MH/s', + bonus_percent: '', auto_renew: true, base_price_amount: '', payment_type: 'fiat', @@ -1726,10 +1728,7 @@ setMinerOfferForm({ label: '', runtime_months: '', - mining_speed_value: '', - mining_speed_unit: 'MH/s', - bonus_speed_value: '', - bonus_speed_unit: 'MH/s', + bonus_percent: '', base_price_amount: '', base_price_currency: 'USD', payment_type: 'fiat', @@ -1756,6 +1755,21 @@ body: JSON.stringify(overrides || { purchased_at: nowDateTimeLocalValue() }), }); setMessage('Miner als gemietet erfasst.'); + setPurchaseMinerForm({ + offer_id: '', + base_offer_id: '', + purchased_at: '', + label: '', + mining_speed_value: '', + mining_speed_unit: '', + bonus_percent: '', + total_cost_amount: '', + currency: '', + reference_price_amount: '', + reference_price_currency: '', + auto_renew: false, + note: '', + }); setPurchaseMinerModalOpen(false); await loadBootstrap(projectKey); } catch (err) { @@ -1772,7 +1786,17 @@ return; } - await purchaseMinerOffer(purchaseMinerForm.offer_id, { + const selectedOffer = availableMinerOffers.find((offer) => String(offer.id) === String(purchaseMinerForm.offer_id)); + if (!selectedOffer) { + setError('Bitte ein gueltiges Miner-Angebot auswaehlen.'); + return; + } + + await purchaseMinerOffer(String(selectedOffer.base_offer_id || selectedOffer.id), { + label: purchaseMinerForm.label || null, + mining_speed_value: purchaseMinerForm.mining_speed_value || null, + mining_speed_unit: purchaseMinerForm.mining_speed_unit || null, + bonus_percent: purchaseMinerForm.bonus_percent || null, purchased_at: purchaseMinerForm.purchased_at || nowDateTimeLocalValue(), total_cost_amount: purchaseMinerForm.total_cost_amount || null, currency: purchaseMinerForm.currency || null, @@ -2413,7 +2437,24 @@ key: 'rent-miner', type: 'button', className: 'mc-button mc-button--ghost', - onClick: () => setPurchaseMinerModalOpen(true), + onClick: () => { + setPurchaseMinerForm({ + offer_id: '', + base_offer_id: '', + purchased_at: '', + label: '', + mining_speed_value: '', + mining_speed_unit: '', + bonus_percent: '', + total_cost_amount: '', + currency: '', + reference_price_amount: '', + reference_price_currency: '', + auto_renew: false, + note: '', + }); + setPurchaseMinerModalOpen(true); + }, disabled: !availableMinerOffers.length, }, 'Neuen Miner mieten'), ]), @@ -2493,14 +2534,14 @@ ]), ]), ]), - panel('Miner-Angebote', 'Angebote fuer neue Miner und eine grobe Reinvestitionsbewertung auf Basis der aktuellen Leistung.', [ + panel('Miner-Angebote', 'Hier werden nur Basis-Miner gepflegt. Alle vorgegebenen Geschwindigkeiten und Preise werden daraus automatisch abgeleitet.', [ h('div', { key: 'actions', className: 'mc-inline-row' }, [ h('button', { key: 'add-offer', type: 'button', className: 'mc-button mc-button--secondary', onClick: () => setMinerOfferModalOpen(true), - }, 'Miner-Angebot anlegen'), + }, 'Basis-Angebot anlegen'), ]), h('div', { key: 'filters', className: 'mc-filter-grid' }, [ inputField(`Min. Geschwindigkeit (${minerOfferFilters.speed_unit === 'kh' ? 'kH/s' : 'MH/s'})`, 'number', minerOfferFilters.speed_min, (value) => setMinerOfferFilters({ ...minerOfferFilters, speed_min: value }), '0.0001'), @@ -2548,7 +2589,7 @@ h('button', { key: 'scenario', type: 'button', - className: cx('mc-button', Number(selectedMinerScenario?.id) === Number(offer.id) ? 'mc-button--secondary' : 'mc-button--ghost'), + className: cx('mc-button', String(selectedMinerScenario?.id) === String(offer.id) ? 'mc-button--secondary' : 'mc-button--ghost'), onClick: () => setSelectedMinerScenarioId(offer.id), }, 'Szenario'), h('button', { @@ -2560,7 +2601,7 @@ label: offer.label, target_amount_fiat: String(offer.base_price_amount ?? offer.effective_price_amount ?? ''), currency: offer.base_price_currency || offer.effective_price_currency || 'EUR', - miner_offer_id: String(offer.id), + miner_offer_id: '', is_active: true, sort_order: 0, }); @@ -2574,7 +2615,12 @@ onClick: () => { setPurchaseMinerForm({ offer_id: String(offer.id), + base_offer_id: String(offer.base_offer_id || offer.id || ''), purchased_at: nowDateTimeLocalValue(), + label: String(offer.label || ''), + mining_speed_value: String(offer.mining_speed_value || ''), + mining_speed_unit: String(offer.mining_speed_unit || ''), + bonus_percent: offer.bonus_percent !== null && offer.bonus_percent !== undefined ? String(offer.bonus_percent) : '', total_cost_amount: offer.effective_price_amount !== null && offer.effective_price_amount !== undefined ? String(offer.effective_price_amount) : '', currency: offer.effective_price_currency || offer.base_price_currency || 'USD', reference_price_amount: offer.base_price_amount !== null && offer.base_price_amount !== undefined ? String(offer.base_price_amount) : '', @@ -2702,8 +2748,7 @@ inputField('Laufzeit in Monaten', 'number', String(costPlanForm.runtime_months), (value) => setCostPlanForm({ ...costPlanForm, runtime_months: Number(value) || 0 })), inputField('Mining-Geschwindigkeit', 'number', costPlanForm.mining_speed_value, (value) => setCostPlanForm({ ...costPlanForm, mining_speed_value: value }), '0.0001'), selectField('Mining-Einheit', costPlanForm.mining_speed_unit, speedUnits, (value) => setCostPlanForm({ ...costPlanForm, mining_speed_unit: value })), - inputField('Bonus-Geschwindigkeit', 'number', costPlanForm.bonus_speed_value, (value) => setCostPlanForm({ ...costPlanForm, bonus_speed_value: value }), '0.0001'), - selectField('Bonus-Einheit', costPlanForm.bonus_speed_unit, speedUnits, (value) => setCostPlanForm({ ...costPlanForm, bonus_speed_unit: value })), + inputField('Bonus-Hashrate in %', 'number', costPlanForm.bonus_percent, (value) => setCostPlanForm({ ...costPlanForm, bonus_percent: value }), '0.01'), inputField(`Basispreis in ${settingsForm.report_currency || 'EUR'}`, 'number', costPlanForm.base_price_amount, (value) => setCostPlanForm({ ...costPlanForm, base_price_amount: value }), '0.000001'), selectField('Zahlungsart', costPlanForm.payment_type, [{ value: 'fiat', label: 'FIAT' }, { value: 'crypto', label: 'Krypto' }], (value) => setCostPlanForm({ ...costPlanForm, payment_type: value })), textareaField('Notiz', costPlanForm.note, (value) => setCostPlanForm({ ...costPlanForm, note: value })), @@ -2733,17 +2778,21 @@ ]), ]), ], () => setPayoutModalOpen(false)) : null, - minerOfferModalOpen ? renderModal('Miner-Angebot anlegen', [ + minerOfferModalOpen ? renderModal('Basis-Miner-Angebot anlegen', [ h('form', { key: 'form', className: 'mc-form', onSubmit: submitMinerOffer }, [ inputField('Label', 'text', minerOfferForm.label, (value) => setMinerOfferForm({ ...minerOfferForm, label: value })), inputField('Laufzeit in Monaten', 'number', minerOfferForm.runtime_months, (value) => setMinerOfferForm({ ...minerOfferForm, runtime_months: value })), - inputField('Mining-Geschwindigkeit', 'number', minerOfferForm.mining_speed_value, (value) => setMinerOfferForm({ ...minerOfferForm, mining_speed_value: value }), '0.0001'), - selectField('Mining-Einheit', minerOfferForm.mining_speed_unit, speedUnits, (value) => setMinerOfferForm({ ...minerOfferForm, mining_speed_unit: value })), - inputField('Bonus-Geschwindigkeit', 'number', minerOfferForm.bonus_speed_value, (value) => setMinerOfferForm({ ...minerOfferForm, bonus_speed_value: value }), '0.0001'), - selectField('Bonus-Einheit', minerOfferForm.bonus_speed_unit, speedUnits, (value) => setMinerOfferForm({ ...minerOfferForm, bonus_speed_unit: value })), + displayField('Basis-Geschwindigkeit', baseOfferSpeedLabel(minerOfferForm.payment_type)), + inputField('Bonus-Hashrate in %', 'number', minerOfferForm.bonus_percent, (value) => setMinerOfferForm({ ...minerOfferForm, bonus_percent: value }), '0.01'), inputField('Basispreis', 'number', minerOfferForm.base_price_amount, (value) => setMinerOfferForm({ ...minerOfferForm, base_price_amount: value }), '0.000001'), - selectField('Basiswährung', minerOfferForm.base_price_currency, selectableFiatCurrencies.map((currency) => currency.code), (value) => setMinerOfferForm({ ...minerOfferForm, base_price_currency: value })), - selectField('Zahlungsart', minerOfferForm.payment_type, [{ value: 'fiat', label: 'FIAT' }, { value: 'crypto', label: 'Krypto' }], (value) => setMinerOfferForm({ ...minerOfferForm, payment_type: value })), + selectField('Zahlungsart', minerOfferForm.payment_type, [{ value: 'fiat', label: 'FIAT' }, { value: 'crypto', label: 'Krypto' }], (value) => setMinerOfferForm({ + ...minerOfferForm, + payment_type: value, + base_price_currency: value === 'crypto' + ? (currentSettings.crypto_currency || selectableCryptoCurrencies[0]?.code || 'DOGE') + : (selectableFiatCurrencies[0]?.code || 'USD'), + })), + selectField('Basiswährung', minerOfferForm.base_price_currency, (minerOfferForm.payment_type === 'crypto' ? selectableCryptoCurrencies : selectableFiatCurrencies).map((currency) => currency.code), (value) => setMinerOfferForm({ ...minerOfferForm, base_price_currency: value })), h('label', { className: 'mc-checkbox' }, [ h('input', { type: 'checkbox', checked: !!minerOfferForm.auto_renew, onChange: (event) => setMinerOfferForm({ ...minerOfferForm, auto_renew: event.target.checked }) }), 'Automatische Verlängerung', @@ -2768,7 +2817,12 @@ const offer = availableMinerOffers.find((item) => String(item.id) === String(value)); setPurchaseMinerForm({ offer_id: value, + base_offer_id: offer ? String(offer.base_offer_id || offer.id || '') : '', purchased_at: purchaseMinerForm.purchased_at || nowDateTimeLocalValue(), + label: offer ? String(offer.label || '') : '', + mining_speed_value: offer && offer.mining_speed_value !== null && offer.mining_speed_value !== undefined ? String(offer.mining_speed_value) : '', + mining_speed_unit: offer?.mining_speed_unit || '', + bonus_percent: offer && offer.bonus_percent !== null && offer.bonus_percent !== undefined ? String(offer.bonus_percent) : '', total_cost_amount: offer && offer.effective_price_amount !== null && offer.effective_price_amount !== undefined ? String(offer.effective_price_amount) : '', currency: offer?.effective_price_currency || offer?.base_price_currency || 'USD', reference_price_amount: offer && offer.reference_price_amount !== null && offer.reference_price_amount !== undefined ? String(offer.reference_price_amount) : '', @@ -2778,6 +2832,8 @@ }); }), inputField('Mietdatum/-zeit', 'datetime-local', purchaseMinerForm.purchased_at, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, purchased_at: value })), + displayField('Gewaehlte Geschwindigkeit', purchaseMinerForm.mining_speed_value && purchaseMinerForm.mining_speed_unit ? `${purchaseMinerForm.mining_speed_value} ${purchaseMinerForm.mining_speed_unit}` : 'n/a'), + displayField('Bonus-Hashrate', purchaseMinerForm.bonus_percent ? `${purchaseMinerForm.bonus_percent}%` : 'kein Bonus'), inputField('Exakter Mietpreis', 'number', purchaseMinerForm.total_cost_amount, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, total_cost_amount: value }), '0.000001'), selectField('Mietwährung', purchaseMinerForm.currency, selectableCurrencies.map((currency) => currency.code), (value) => setPurchaseMinerForm({ ...purchaseMinerForm, currency: value })), inputField('Referenzpreis', 'number', purchaseMinerForm.reference_price_amount, (value) => setPurchaseMinerForm({ ...purchaseMinerForm, reference_price_amount: value }), '0.000001'), @@ -2796,11 +2852,11 @@ targetModalOpen ? renderModal('Ziel anlegen', [ h('form', { key: 'form', className: 'mc-form', onSubmit: submitTarget }, [ inputField('Label', 'text', targetForm.label, (value) => setTargetForm({ ...targetForm, label: value })), - selectField('Angebots-Verknuepfung', targetForm.miner_offer_id || '', [{ value: '', label: 'Kein verknuepftes Angebot' }].concat(availableMinerOffers.map((offer) => ({ + selectField('Angebots-Verknuepfung', targetForm.miner_offer_id || '', [{ value: '', label: 'Kein verknuepftes Angebot' }].concat(currentMinerOffers.map((offer) => ({ value: String(offer.id), - label: `${offer.label} · ${fmtNumber(offer.base_price_amount ?? offer.effective_price_amount, 6)} ${offer.base_price_currency || offer.effective_price_currency}`, + label: `${offer.label} · ${fmtNumber(offer.base_price_amount, 6)} ${offer.base_price_currency}`, }))), (value) => { - const offer = availableMinerOffers.find((item) => String(item.id) === String(value)); + const offer = currentMinerOffers.find((item) => String(item.id) === String(value)); setTargetForm({ ...targetForm, miner_offer_id: value, @@ -3051,6 +3107,18 @@ return `${label ? label + ' ' : ''}${fmtNumber(value, 4)} ${unit}`; } + function baseOfferSpeedLabel(paymentType) { + return String(paymentType || 'fiat') === 'crypto' ? '75 kH/s' : '50 kH/s'; + } + + function formatHashrateWithBonus(speedValue, speedUnit, bonusValue, bonusUnit) { + const parts = [ + formatSpeed(speedValue, speedUnit, 'Basis'), + Number(bonusValue) > 0 ? formatSpeed(bonusValue, bonusUnit, 'Bonus') : '', + ].filter(Boolean); + return parts.length ? parts.join(' · ') : 'n/a'; + } + function formatAdaptiveSpeed(valueMh) { const numericValue = Number(valueMh); if (!Number.isFinite(numericValue)) { diff --git a/modules/mining-checker/src/Api/Router.php b/modules/mining-checker/src/Api/Router.php index e3701b0..60ecb38 100644 --- a/modules/mining-checker/src/Api/Router.php +++ b/modules/mining-checker/src/Api/Router.php @@ -24,6 +24,10 @@ final class Router private const LONG_REQUEST_BUDGET_SECONDS = 8.0; private const OVERVIEW_WINDOW_DAYS = 15; private const FX_FETCH_MAX_AGE_HOURS = 3.0; + private const BASE_OFFER_SPEEDS = [ + 'fiat' => ['value' => 50.0, 'unit' => 'kH/s'], + 'crypto' => ['value' => 75.0, 'unit' => 'kH/s'], + ]; private string $moduleBasePath; private ModuleConfig $config; @@ -1679,14 +1683,18 @@ final class Router private function saveCostPlan(string $projectKey, array $input): array { $projectTimezone = $this->projectTimezone($projectKey); + $miningSpeedValue = $this->optionalDecimal($input['mining_speed_value'] ?? null); + $miningSpeedUnit = $this->optionalSpeedUnit($input['mining_speed_unit'] ?? null); + $bonusPercent = $this->optionalDecimal($input['bonus_percent'] ?? null); $payload = [ 'label' => $this->requiredString($input['label'] ?? null, 'label', 120), 'purchased_at' => $this->requiredDateTime($input['starts_at'] ?? null, 'starts_at', $projectTimezone), 'runtime_months' => $this->requiredPositiveInt($input['runtime_months'] ?? null, 'runtime_months'), - 'mining_speed_value' => $this->optionalDecimal($input['mining_speed_value'] ?? null), - 'mining_speed_unit' => $this->optionalSpeedUnit($input['mining_speed_unit'] ?? null), - 'bonus_speed_value' => $this->optionalDecimal($input['bonus_speed_value'] ?? null), - 'bonus_speed_unit' => $this->optionalSpeedUnit($input['bonus_speed_unit'] ?? null), + 'mining_speed_value' => $miningSpeedValue, + 'mining_speed_unit' => $miningSpeedUnit, + 'bonus_speed_value' => $this->bonusSpeedFromPercent($miningSpeedValue, $miningSpeedUnit, $bonusPercent), + 'bonus_speed_unit' => $bonusPercent !== null ? $miningSpeedUnit : null, + 'bonus_percent' => $bonusPercent, 'auto_renew' => !empty($input['auto_renew']) ? 1 : 0, 'base_price_amount' => $this->requiredDecimal($input['base_price_amount'] ?? ($input['total_cost_amount'] ?? null), 'base_price_amount'), 'payment_type' => $this->enumValue($input['payment_type'] ?? 'fiat', ['fiat', 'crypto'], 'payment_type'), @@ -1698,8 +1706,11 @@ final class Router throw new ApiException('Mining-Geschwindigkeit und Einheit muessen gemeinsam gesetzt oder beide leer sein.', 422); } - if (($payload['bonus_speed_value'] === null) xor ($payload['bonus_speed_unit'] === null)) { - throw new ApiException('Bonus-Geschwindigkeit und Einheit muessen gemeinsam gesetzt oder beide leer sein.', 422); + if ($bonusPercent !== null && ($miningSpeedValue === null || $miningSpeedUnit === null)) { + throw new ApiException('Bonus-Prozent setzt eine Mining-Geschwindigkeit voraus.', 422); + } + if ($bonusPercent !== null && $bonusPercent < 0) { + throw new ApiException('Bonus-Prozent darf nicht negativ sein.', 422); } $settings = $this->settings($projectKey); @@ -1787,22 +1798,31 @@ final class Router private function saveMinerOffer(string $projectKey, array $input): array { + $paymentType = $this->enumValue($input['payment_type'] ?? 'fiat', ['fiat', 'crypto'], 'payment_type'); + $baseSpeed = self::BASE_OFFER_SPEEDS[$paymentType] ?? self::BASE_OFFER_SPEEDS['fiat']; + $bonusPercent = $this->optionalDecimal($input['bonus_percent'] ?? null); + $basePriceCurrency = $this->requiredCurrency($input['base_price_currency'] ?? ($input['reference_price_currency'] ?? $input['price_currency'] ?? null), 'base_price_currency'); $payload = [ 'label' => $this->requiredString($input['label'] ?? null, 'label', 120), 'runtime_months' => $this->optionalPositiveInt($input['runtime_months'] ?? null), - 'mining_speed_value' => $this->optionalDecimal($input['mining_speed_value'] ?? null), - 'mining_speed_unit' => $this->optionalSpeedUnit($input['mining_speed_unit'] ?? null), - 'bonus_speed_value' => $this->optionalDecimal($input['bonus_speed_value'] ?? null), - 'bonus_speed_unit' => $this->optionalSpeedUnit($input['bonus_speed_unit'] ?? null), + 'mining_speed_value' => $baseSpeed['value'], + 'mining_speed_unit' => $baseSpeed['unit'], + 'bonus_speed_value' => $this->bonusSpeedFromPercent((float) $baseSpeed['value'], (string) $baseSpeed['unit'], $bonusPercent), + 'bonus_speed_unit' => $bonusPercent !== null ? (string) $baseSpeed['unit'] : null, + 'bonus_percent' => $bonusPercent, 'base_price_amount' => $this->requiredDecimal($input['base_price_amount'] ?? ($input['reference_price_amount'] ?? $input['price_amount'] ?? null), 'base_price_amount'), - 'base_price_currency' => $this->requiredCurrency($input['base_price_currency'] ?? ($input['reference_price_currency'] ?? $input['price_currency'] ?? null), 'base_price_currency'), - 'payment_type' => $this->enumValue($input['payment_type'] ?? 'fiat', ['fiat', 'crypto'], 'payment_type'), + 'base_price_currency' => $basePriceCurrency, + 'payment_type' => $paymentType, 'auto_renew' => !empty($input['auto_renew']) ? 1 : 0, 'note' => $this->optionalString($input['note'] ?? null, 1000), 'is_active' => !empty($input['is_active']) ? 1 : 0, ]; - $this->assertCurrencyType($payload['base_price_currency'], false, 'base_price_currency'); + if ($bonusPercent !== null && $bonusPercent < 0) { + throw new ApiException('Bonus-Prozent darf nicht negativ sein.', 422); + } + + $this->assertCurrencyType($payload['base_price_currency'], $paymentType === 'crypto', 'base_price_currency'); return $this->repository()->saveMinerOffer($projectKey, $payload); } @@ -1817,14 +1837,31 @@ final class Router $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']); + $selectedSpeedValue = $this->optionalDecimal($input['mining_speed_value'] ?? null); + $selectedSpeedUnit = $this->optionalSpeedUnit($input['mining_speed_unit'] ?? null); + if (($selectedSpeedValue === null) xor ($selectedSpeedUnit === null)) { + throw new ApiException('Mining-Geschwindigkeit und Einheit muessen gemeinsam gesetzt oder beide leer sein.', 422); + } + + $resolvedSpeedValue = $selectedSpeedValue ?? $this->optionalDecimal($offer['mining_speed_value'] ?? null); + $resolvedSpeedUnit = $selectedSpeedUnit ?? $this->optionalSpeedUnit($offer['mining_speed_unit'] ?? null); + $resolvedBonusPercent = $this->offerBonusPercent($offer); + $resolvedBonusValue = $this->bonusSpeedFromPercent($resolvedSpeedValue, $resolvedSpeedUnit, $resolvedBonusPercent); + $resolvedBonusUnit = $resolvedBonusValue !== null ? $resolvedSpeedUnit : null; $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 || !$isAutoRenew) { + if ($purchaseCost === null) { $purchaseCost = $this->resolveOfferPurchaseCost(array_merge($offer, [ + 'mining_speed_value' => $resolvedSpeedValue, + 'mining_speed_unit' => $resolvedSpeedUnit, + 'bonus_speed_value' => $resolvedBonusValue, + 'bonus_speed_unit' => $resolvedBonusUnit, + 'base_price_amount' => $this->optionalDecimal($input['reference_price_amount'] ?? null) ?? ($offer['base_price_amount'] ?? null), + 'base_price_currency' => $this->optionalCurrency($input['reference_price_currency'] ?? null) ?? ($offer['base_price_currency'] ?? null), 'price_currency' => $purchaseCurrency !== '' ? $purchaseCurrency : ($offer['price_currency'] ?? ''), ])); } @@ -1836,12 +1873,12 @@ final class Router return $this->repository()->purchaseMiner($projectKey, $offerId, [ 'purchased_at' => $purchasedAt, - 'label' => $offer['label'], + 'label' => $this->optionalString($input['label'] ?? ($offer['label'] ?? null), 120) ?? $offer['label'], 'runtime_months' => $offer['runtime_months'] ?? null, - 'mining_speed_value' => $offer['mining_speed_value'] ?? null, - 'mining_speed_unit' => $offer['mining_speed_unit'] ?? null, - 'bonus_speed_value' => $offer['bonus_speed_value'] ?? null, - 'bonus_speed_unit' => $offer['bonus_speed_unit'] ?? null, + 'mining_speed_value' => $resolvedSpeedValue, + 'mining_speed_unit' => $resolvedSpeedUnit, + 'bonus_speed_value' => $resolvedBonusValue, + 'bonus_speed_unit' => $resolvedBonusUnit, 'total_cost_amount' => $purchaseCost, 'currency' => $purchaseCurrency !== '' ? $purchaseCurrency : $offer['price_currency'], 'usd_reference_amount' => $offer['usd_reference_amount'] ?? null, @@ -2440,6 +2477,39 @@ final class Router return (float) ($baseAmount ?? $offer['price_amount'] ?? 0); } + private function bonusSpeedFromPercent(?float $speedValue, ?string $speedUnit, ?float $bonusPercent): ?float + { + if ($speedValue === null || $speedUnit === null || $bonusPercent === null) { + return null; + } + + return round($speedValue * ($bonusPercent / 100), 4); + } + + private function offerBonusPercent(array $offer): ?float + { + $speedValue = $this->optionalDecimal($offer['mining_speed_value'] ?? null); + $speedUnit = $this->optionalSpeedUnit($offer['mining_speed_unit'] ?? null); + $bonusValue = $this->optionalDecimal($offer['bonus_speed_value'] ?? null); + $bonusUnit = $this->optionalSpeedUnit($offer['bonus_speed_unit'] ?? null); + if ($speedValue === null || $speedUnit === null || $bonusValue === null || $bonusUnit === null) { + return null; + } + + $speedMh = $this->hashrateToMh($speedValue, $speedUnit); + $bonusMh = $this->hashrateToMh($bonusValue, $bonusUnit); + if ($speedMh <= 0 || $bonusMh <= 0) { + return null; + } + + return ($bonusMh / $speedMh) * 100; + } + + private function hashrateToMh(float $value, string $unit): float + { + return $unit === 'kH/s' ? $value / 1000 : $value; + } + private function pdo(): PDO { if ($this->pdo === null) { diff --git a/modules/mining-checker/src/Domain/AnalyticsService.php b/modules/mining-checker/src/Domain/AnalyticsService.php index 6e637cd..653a2b0 100644 --- a/modules/mining-checker/src/Domain/AnalyticsService.php +++ b/modules/mining-checker/src/Domain/AnalyticsService.php @@ -7,6 +7,16 @@ use Modules\MiningChecker\Support\ApiException; final class AnalyticsService { + private const BASE_OFFER_SPEEDS = [ + 'fiat' => ['value' => 50.0, 'unit' => 'kH/s'], + 'crypto' => ['value' => 75.0, 'unit' => 'kH/s'], + ]; + + private const OFFER_SPEED_MULTIPLIERS = [ + 1, 2, 3, 4, 5, 6, 7, 8, 9, 10, + 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, + ]; + private ?FxService $fx; public function __construct(?FxService $fx = null) @@ -245,7 +255,7 @@ final class AnalyticsService $purchasedMiners )); $offerSummary = []; - foreach ($minerOffers as $offer) { + foreach ($this->expandOfferVariants($minerOffers, $settings) as $offer) { $offerSummary[] = $this->evaluateMinerOffer($offer, $latest, $latestPriceByCurrency, $currentHashrateMh, $settings); } @@ -256,7 +266,7 @@ final class AnalyticsService $linkedOffer = null; if (is_numeric($target['miner_offer_id'] ?? null)) { foreach ($offerSummary as $offer) { - if ((int) ($offer['id'] ?? 0) === (int) $target['miner_offer_id']) { + if ((int) ($offer['base_offer_id'] ?? $offer['id'] ?? 0) === (int) $target['miner_offer_id']) { $linkedOffer = $offer; break; } @@ -823,6 +833,102 @@ final class AnalyticsService }; } + private function expandOfferVariants(array $offers, array $settings): array + { + $expanded = []; + foreach ($offers as $offer) { + $paymentType = $this->normalizeOfferPaymentType($offer); + $baseSpeed = self::BASE_OFFER_SPEEDS[$paymentType] ?? self::BASE_OFFER_SPEEDS['fiat']; + $baseSpeedValue = (float) $baseSpeed['value']; + $baseSpeedUnit = (string) $baseSpeed['unit']; + $baseBonusPercent = $this->deriveBonusPercent($offer, $baseSpeedValue, $baseSpeedUnit); + $basePriceAmount = is_numeric($offer['base_price_amount'] ?? null) + ? (float) $offer['base_price_amount'] + : (is_numeric($offer['reference_price_amount'] ?? null) + ? (float) $offer['reference_price_amount'] + : (is_numeric($offer['usd_reference_amount'] ?? null) + ? (float) $offer['usd_reference_amount'] + : (is_numeric($offer['price_amount'] ?? null) ? (float) $offer['price_amount'] : null))); + + foreach (self::OFFER_SPEED_MULTIPLIERS as $multiplier) { + $derivedSpeedValue = $baseSpeedValue * $multiplier; + $derivedBonusValue = $baseBonusPercent !== null + ? round($derivedSpeedValue * ($baseBonusPercent / 100), 4) + : null; + $derivedPriceAmount = $basePriceAmount !== null + ? round($basePriceAmount * $multiplier, 8) + : null; + + $expanded[] = array_merge($offer, [ + 'id' => sprintf('%s:%s:%s', (string) ($offer['id'] ?? 'offer'), $paymentType, rtrim(rtrim(number_format($derivedSpeedValue, 4, '.', ''), '0'), '.')), + 'base_offer_id' => $offer['id'] ?? null, + 'base_offer_label' => $offer['label'] ?? null, + 'payment_type' => $paymentType, + 'label' => trim(((string) ($offer['label'] ?? 'Miner-Angebot')) . ' · ' . $this->formatSpeedLabel($derivedSpeedValue, $baseSpeedUnit)), + 'mining_speed_value' => $derivedSpeedValue, + 'mining_speed_unit' => $baseSpeedUnit, + 'bonus_speed_value' => $derivedBonusValue, + 'bonus_speed_unit' => $derivedBonusValue !== null ? $baseSpeedUnit : null, + 'bonus_percent' => $baseBonusPercent, + 'speed_factor' => $multiplier, + 'base_speed_value' => $baseSpeedValue, + 'base_speed_unit' => $baseSpeedUnit, + 'base_price_amount' => $derivedPriceAmount, + 'reference_price_amount' => $derivedPriceAmount, + ]); + } + } + + usort($expanded, static function (array $left, array $right): int { + $paymentCompare = strcmp((string) ($left['payment_type'] ?? ''), (string) ($right['payment_type'] ?? '')); + if ($paymentCompare !== 0) { + return $paymentCompare; + } + + $runtimeCompare = ((int) ($left['runtime_months'] ?? 0)) <=> ((int) ($right['runtime_months'] ?? 0)); + if ($runtimeCompare !== 0) { + return $runtimeCompare; + } + + return ((int) ($left['speed_factor'] ?? 0)) <=> ((int) ($right['speed_factor'] ?? 0)); + }); + + return $expanded; + } + + private function normalizeOfferPaymentType(array $offer): string + { + $paymentType = strtolower(trim((string) ($offer['payment_type'] ?? ''))); + if (in_array($paymentType, ['fiat', 'crypto'], true)) { + return $paymentType; + } + + return !empty($offer['price_currency']) && in_array(strtoupper((string) $offer['price_currency']), ['ADA','ARB','BNB','BTC','DAI','DOGE','DOT','ETH','LINK','LTC','SOL','USDC','USDT','XRP'], true) + ? 'crypto' + : 'fiat'; + } + + private function deriveBonusPercent(array $offer, float $speedValue, string $speedUnit): ?float + { + if (is_numeric($offer['bonus_percent'] ?? null)) { + $numeric = (float) $offer['bonus_percent']; + return $numeric >= 0 ? $numeric : null; + } + + $baseSpeedMh = $this->normalizeHashrateMh($speedValue, $speedUnit); + $bonusMh = $this->normalizeHashrateMh($offer['bonus_speed_value'] ?? null, $offer['bonus_speed_unit'] ?? null); + if ($baseSpeedMh <= 0 || $bonusMh <= 0) { + return null; + } + + return ($bonusMh / $baseSpeedMh) * 100; + } + + private function formatSpeedLabel(float $value, string $unit): string + { + return rtrim(rtrim(number_format($value, 4, '.', ''), '0'), '.') . ' ' . $unit; + } + private function evaluateMinerOffer(array $offer, array $latest, array $latestPriceByCurrency, float $currentHashrateMh, array $settings): array { $offerHashrateMh = $this->normalizeHashrateMh($offer['mining_speed_value'] ?? null, $offer['mining_speed_unit'] ?? null)