diff --git a/custom/apps/mining-checker/assets/js/app.js b/custom/apps/mining-checker/assets/js/app.js index 3e0c3483..d8b0073a 100644 --- a/custom/apps/mining-checker/assets/js/app.js +++ b/custom/apps/mining-checker/assets/js/app.js @@ -477,6 +477,9 @@ daily_cost_currency: 'EUR', report_currency: 'EUR', crypto_currency: 'DOGE', + crypto_base_price_amount: '', + crypto_base_price_currency: 'USD', + fiat_base_price_amount: '', preferred_currencies: ['DOGE', 'USD', 'EUR'], cost_plans: [], currencies: [], @@ -882,6 +885,13 @@ report_currency: 'EUR', crypto_currency: 'DOGE', }); + const [offerPreviewForm, setOfferPreviewForm] = useState({ + crypto_base_price_amount: '', + crypto_base_price_currency: 'USD', + fiat_base_price_amount: '', + }); + const [previewMinerOffers, setPreviewMinerOffers] = useState([]); + const [previewOffersLoading, setPreviewOffersLoading] = useState(false); const [moduleAuthForm, setModuleAuthForm] = useState({ required: true, users: '', @@ -943,16 +953,6 @@ }); const [walletWithdrawalModalOpen, setWalletWithdrawalModalOpen] = useState(false); const [walletWithdrawalRows, setWalletWithdrawalRows] = useState([]); - const [minerOfferForm, setMinerOfferForm] = useState({ - label: '', - base_price_amount: '', - base_price_currency: 'USD', - payment_type: 'crypto', - auto_renew: false, - note: '', - is_active: true, - }); - const [minerOfferModalOpen, setMinerOfferModalOpen] = useState(false); const [purchaseMinerModalOpen, setPurchaseMinerModalOpen] = useState(false); const [purchaseMinerForm, setPurchaseMinerForm] = useState({ offer_id: '', @@ -1003,7 +1003,6 @@ const currentPayouts = walletTransferRows.length ? walletTransferRows : (Array.isArray(currentSettings.payouts) ? currentSettings.payouts : []); const currentWalletWithdrawals = walletWithdrawalRows.length ? walletWithdrawalRows : (Array.isArray(currentSettings.wallet_withdrawals) ? currentSettings.wallet_withdrawals : []); const currentWalletSnapshots = Array.isArray(payload?.wallet_snapshots) ? payload.wallet_snapshots : []; - const currentMinerOffers = Array.isArray(currentSettings.miner_offers) ? currentSettings.miner_offers : []; const currentPurchasedMiners = Array.isArray(currentSettings.purchased_miners) ? currentSettings.purchased_miners : []; const currentMiningCurrency = String((latest && latest.coin_currency) || currentSettings.crypto_currency || 'DOGE').toUpperCase(); const latestMeasurementDate = latest && latest.measured_at ? parseStoredUtcDate(latest.measured_at) : null; @@ -1020,12 +1019,6 @@ : `Verwendet den letzten Upload von ${fmtDate(latest.measured_at)} mit ${fmtNumber(fullTransferCoins, 6)} ${currentMiningCurrency}. Backend erlaubt den Kompletttransfer nur, wenn dieser Upload hoechstens 3 Minuten alt ist.`; const latestWalletSnapshot = currentWalletSnapshots.length ? currentWalletSnapshots[0] : null; const currentWalletMiningBalance = walletAssetBalance(latestWalletSnapshot, currentMiningCurrency); - const renewableOfferIds = new Set( - currentMinerOffers - .filter((offer) => !!offer.auto_renew) - .map((offer) => Number(offer.id)) - .filter((value) => Number.isFinite(value) && value > 0) - ); const preferredCurrencyCodes = Array.isArray(currentSettings.preferred_currencies) ? currentSettings.preferred_currencies.map((code) => String(code || '').toUpperCase()).filter(Boolean) : []; @@ -1034,8 +1027,10 @@ ? currencies.filter((currency) => preferredCurrencySet.has(String(currency.code || '').toUpperCase())) : currencies; const selectableCurrencies = preferredSelectableCurrencies.length ? preferredSelectableCurrencies : currencies; - const evaluatedMinerOffers = Array.isArray(payload?.summary?.miner_offers) ? payload.summary.miner_offers : []; + const evaluatedMinerOffers = Array.isArray(previewMinerOffers) ? previewMinerOffers : []; const selectedOfferType = String(minerOfferFilters.offer_type || 'crypto').toLowerCase() === 'fiat' ? 'fiat' : 'crypto'; + const hasCryptoOfferBasis = Number(offerPreviewForm.crypto_base_price_amount) > 0; + const hasFiatOfferBasis = Number(offerPreviewForm.fiat_base_price_amount) > 0; const filteredMinerOffers = evaluatedMinerOffers.filter((offer) => { const paymentType = String(offer.payment_type || '').toLowerCase(); const speedMin = minerOfferFilters.speed_min === '' ? null : Number(minerOfferFilters.speed_min); @@ -1111,7 +1106,7 @@ ? 'crypto' : 'fiat', is_active: miner.is_active !== false, - can_toggle_auto_renew: Number(miner.runtime_months) > 0 && renewableOfferIds.has(Number(miner.miner_offer_id)), + can_toggle_auto_renew: Number(miner.runtime_months) > 0, 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) => ({ @@ -1149,6 +1144,63 @@ } }, [scenarioMinerOffers, selectedMinerScenarioId]); + useEffect(() => { + setOfferPreviewForm((current) => { + const cryptoCurrency = String(currentSettings.crypto_currency || 'DOGE').toUpperCase(); + const selectedCurrency = String(current.crypto_base_price_currency || 'USD').toUpperCase(); + if (selectedCurrency === 'USD' || selectedCurrency === cryptoCurrency) { + return current; + } + + return { + ...current, + crypto_base_price_currency: 'USD', + }; + }); + }, [currentSettings.crypto_currency]); + + useEffect(() => { + const shouldPreview = selectedOfferType === 'crypto' + ? Number(offerPreviewForm.crypto_base_price_amount) > 0 + : Number(offerPreviewForm.fiat_base_price_amount) > 0 && Number(minerOfferFilters.speed_min) > 0; + if (!shouldPreview) { + setPreviewMinerOffers([]); + setPreviewOffersLoading(false); + return undefined; + } + + const timeoutId = window.setTimeout(async () => { + setPreviewOffersLoading(true); + try { + const data = await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/offer-preview`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + crypto_base_price_amount: offerPreviewForm.crypto_base_price_amount || null, + crypto_base_price_currency: offerPreviewForm.crypto_base_price_currency || 'USD', + fiat_base_price_amount: offerPreviewForm.fiat_base_price_amount || null, + }), + }); + setPreviewMinerOffers(Array.isArray(data?.miner_offers) ? data.miner_offers : []); + } catch (err) { + setPreviewMinerOffers([]); + setError(err.message); + } finally { + setPreviewOffersLoading(false); + } + }, 250); + + return () => window.clearTimeout(timeoutId); + }, [ + apiBase, + offerPreviewForm.crypto_base_price_amount, + offerPreviewForm.crypto_base_price_currency, + offerPreviewForm.fiat_base_price_amount, + minerOfferFilters.speed_min, + projectKey, + selectedOfferType, + ]); + useEffect(() => { if (!purchaseMinerModalOpen) { return; @@ -1164,7 +1216,7 @@ 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 || ''), + base_offer_id: current.base_offer_id || String(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 || ''), @@ -2110,41 +2162,6 @@ } } - async function submitMinerOffer(event) { - event.preventDefault(); - setSaving(true); - setError(''); - try { - const offerPayload = { - ...minerOfferForm, - base_price_currency: minerOfferForm.payment_type === 'crypto' - ? (minerOfferForm.base_price_currency || 'USD') - : 'EUR', - }; - await request(`${apiBase}/projects/${encodeURIComponent(projectKey)}/miner-offers`, { - method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify(offerPayload), - }); - setMessage('Miner-Angebot gespeichert.'); - setMinerOfferForm({ - label: '', - base_price_amount: '', - base_price_currency: 'USD', - payment_type: 'crypto', - auto_renew: false, - note: '', - is_active: true, - }); - setMinerOfferModalOpen(false); - await loadBootstrap(projectKey); - } catch (err) { - setError(err.message); - } finally { - setSaving(false); - } - } - async function purchaseMinerOffer(offerId, overrides) { setSaving(true); setError(''); @@ -2192,7 +2209,8 @@ return; } - await purchaseMinerOffer(String(selectedOffer.base_offer_id || selectedOffer.id), { + await purchaseMinerOffer(String(selectedOffer.id), { + computed_offer: selectedOffer, label: purchaseMinerForm.label || null, mining_speed_value: purchaseMinerForm.mining_speed_value || null, mining_speed_unit: purchaseMinerForm.mining_speed_unit || null, @@ -3055,15 +3073,13 @@ : 'Crypto-Angebote sind aktiv. Solange kein Wallet-Bestand erkannt wird, bleiben die Krypto-Angebote sichtbar.') : 'Fiat-Angebote sind aktiv. Die Berechnung startet erst, wenn du eine Geschwindigkeit eingibst.', [ - h('div', { key: 'actions', className: 'mc-inline-row' }, [ - h('button', { - key: 'add-offer', - type: 'button', - className: 'mc-button mc-button--secondary', - onClick: () => setMinerOfferModalOpen(true), - }, 'Basis-Angebot anlegen'), - ]), h('div', { key: 'filters', className: 'mc-filter-grid' }, [ + selectedOfferType === 'crypto' + ? inputField('Basispreis fuer 50 kH/s auf 3 Monate', 'number', offerPreviewForm.crypto_base_price_amount, (value) => setOfferPreviewForm({ ...offerPreviewForm, crypto_base_price_amount: value }), '0.000001') + : inputField('Basispreis fuer 100 kH/s auf 3 Monate in EUR', 'number', offerPreviewForm.fiat_base_price_amount, (value) => setOfferPreviewForm({ ...offerPreviewForm, fiat_base_price_amount: value }), '0.000001'), + selectedOfferType === 'crypto' + ? selectField('Basiswaehrung', offerPreviewForm.crypto_base_price_currency || 'USD', Array.from(new Set(['USD', String(currentSettings.crypto_currency || 'DOGE').toUpperCase()])), (value) => setOfferPreviewForm({ ...offerPreviewForm, crypto_base_price_currency: value || 'USD' })) + : displayField('Basiswaehrung', 'EUR'), selectField('Angebotsart', minerOfferFilters.offer_type, [ { value: 'crypto', label: 'Crypto' }, { value: 'fiat', label: 'Fiat' }, @@ -3140,7 +3156,7 @@ onClick: () => { setPurchaseMinerForm({ offer_id: String(offer.id), - base_offer_id: String(offer.base_offer_id || offer.id || ''), + base_offer_id: String(offer.id || ''), purchased_at: nowDateTimeLocalValue(), label: String(offer.label || ''), mining_speed_value: String(offer.mining_speed_value || ''), @@ -3159,7 +3175,15 @@ }, 'Mieten'), ]), ])) - : [h('tr', { key: 'empty' }, h('td', { colSpan: 7 }, selectedOfferType === 'fiat' && minerOfferFilters.speed_min === '' ? 'Bitte zuerst eine Geschwindigkeit eingeben, damit Fiat-Angebote berechnet werden.' : 'Keine Angebote passen auf die gesetzten Filter.'))] + : [h('tr', { key: 'empty' }, h('td', { colSpan: 7 }, previewOffersLoading + ? 'Berechne Angebote …' + : !hasCryptoOfferBasis && selectedOfferType === 'crypto' + ? 'Bitte zuerst direkt hier den Crypto-Basispreis fuer 50 kH/s auf 3 Monate eingeben.' + : !hasFiatOfferBasis && selectedOfferType === 'fiat' + ? 'Bitte zuerst direkt hier den Fiat-Basispreis fuer 100 kH/s auf 3 Monate eingeben.' + : selectedOfferType === 'fiat' && minerOfferFilters.speed_min === '' + ? 'Bitte zuerst eine Geschwindigkeit eingeben, damit Fiat-Angebote berechnet werden.' + : 'Keine Angebote passen auf die gesetzten Filter.'))] ), ]), ]), @@ -3317,38 +3341,6 @@ ]), ]), ], () => setWalletWithdrawalModalOpen(false)) : null, - minerOfferModalOpen ? renderModal('Basis-Miner-Angebot anlegen', [ - h('form', { key: 'form', className: 'mc-form', onSubmit: submitMinerOffer }, [ - inputField('Label', 'text', minerOfferForm.label, (value) => setMinerOfferForm({ ...minerOfferForm, label: value })), - displayField('Basis-Geschwindigkeit', baseOfferSpeedLabel(minerOfferForm.payment_type)), - displayField('Basis-Laufzeit', '3 Monate'), - displayField('Ableitung', baseOfferDerivationLabel(minerOfferForm.payment_type)), - inputField(baseOfferPriceLabel(minerOfferForm.payment_type, currentSettings.crypto_currency), 'number', minerOfferForm.base_price_amount, (value) => setMinerOfferForm({ ...minerOfferForm, base_price_amount: value }), '0.000001'), - selectField('Zahlungsart', minerOfferForm.payment_type, [{ value: 'fiat', label: 'FIAT' }, { value: 'crypto', label: 'Krypto' }], (value) => setMinerOfferForm({ - ...minerOfferForm, - payment_type: value, - base_price_currency: value === 'crypto' - ? 'USD' - : 'EUR', - })), - minerOfferForm.payment_type === 'crypto' - ? selectField('Basiswaehrung', minerOfferForm.base_price_currency, Array.from(new Set(['USD', String(currentSettings.crypto_currency || 'DOGE').toUpperCase()])), (value) => setMinerOfferForm({ ...minerOfferForm, base_price_currency: value || 'USD' })) - : displayField('Basiswaehrung', 'EUR'), - 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', - ]), - textareaField('Notiz', minerOfferForm.note, (value) => setMinerOfferForm({ ...minerOfferForm, note: value })), - h('label', { className: 'mc-checkbox' }, [ - h('input', { type: 'checkbox', checked: !!minerOfferForm.is_active, onChange: (event) => setMinerOfferForm({ ...minerOfferForm, is_active: event.target.checked }) }), - 'Als verfuegbar markieren', - ]), - h('div', { className: 'mc-inline-row' }, [ - h('button', { type: 'button', className: 'mc-button mc-button--ghost', onClick: () => setMinerOfferModalOpen(false) }, 'Abbrechen'), - h('button', { type: 'submit', className: 'mc-button mc-button--secondary', disabled: saving }, saving ? 'Speichert …' : 'Angebot speichern'), - ]), - ]), - ], () => setMinerOfferModalOpen(false)) : null, purchaseMinerModalOpen ? renderModal('Neuen Miner mieten', [ h('form', { key: 'form', className: 'mc-form', onSubmit: submitPurchaseMiner }, [ selectField('Angebot', purchaseMinerForm.offer_id, [{ value: '', label: 'Bitte waehlen' }].concat(scenarioMinerOffers.map((offer) => ({ @@ -3358,7 +3350,7 @@ const offer = scenarioMinerOffers.find((item) => String(item.id) === String(value)); setPurchaseMinerForm({ offer_id: value, - base_offer_id: offer ? String(offer.base_offer_id || offer.id || '') : '', + base_offer_id: offer ? String(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) : '', @@ -3395,19 +3387,7 @@ 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(currentMinerOffers.map((offer) => ({ - value: String(offer.id), - label: `${offer.label} · ${fmtNumber(offer.base_price_amount, 6)} ${offer.base_price_currency}`, - }))), (value) => { - const offer = currentMinerOffers.find((item) => String(item.id) === String(value)); - setTargetForm({ - ...targetForm, - miner_offer_id: value, - label: targetForm.label || offer?.label || '', - target_amount_fiat: offer ? String(offer.base_price_amount ?? offer.effective_price_amount ?? '') : targetForm.target_amount_fiat, - currency: offer?.base_price_currency || offer?.effective_price_currency || targetForm.currency, - }); - }), + displayField('Angebots-Verknuepfung', 'Direkte Angebots-Links sind bei Live-Berechnung deaktiviert.'), inputField('Betrag', 'number', targetForm.target_amount_fiat, (value) => setTargetForm({ ...targetForm, target_amount_fiat: value }), '0.01'), selectField('Waehrung', targetForm.currency, selectableCurrencies.map((currency) => currency.code), (value) => setTargetForm({ ...targetForm, currency: value })), inputField('Sortierung', 'number', String(targetForm.sort_order), (value) => setTargetForm({ ...targetForm, sort_order: Number(value) || 0 })), @@ -3684,22 +3664,6 @@ return `${label ? label + ' ' : ''}${fmtNumber(value, 4)} ${unit}`; } - function baseOfferSpeedLabel(paymentType) { - return String(paymentType || 'fiat') === 'crypto' ? '50 kH/s' : '100 kH/s'; - } - - function baseOfferPriceLabel(paymentType, cryptoCurrency) { - return String(paymentType || 'fiat') === 'crypto' - ? `Basispreis fuer 50 kH/s auf 3 Monate in USD oder ${String(cryptoCurrency || 'DOGE').toUpperCase()}` - : 'Basispreis fuer 100 kH/s auf 3 Monate in EUR'; - } - - function baseOfferDerivationLabel(paymentType) { - return String(paymentType || 'fiat') === 'crypto' - ? 'Varianten fuer 3, 6, 12, 18, 24 und 36 Monate mit festen Rabatten und Bonuswerten' - : 'Varianten fuer 3, 6 und 12 Monate mit festen Rabatten und Bonuswerten'; - } - function formatHashrateWithBonus(speedValue, speedUnit, bonusValue, bonusUnit) { const parts = [ formatSpeed(speedValue, speedUnit, 'Basis'), diff --git a/custom/apps/mining-checker/src/Api/Router.php b/custom/apps/mining-checker/src/Api/Router.php index 3ed90ca8..29ab0cb9 100644 --- a/custom/apps/mining-checker/src/Api/Router.php +++ b/custom/apps/mining-checker/src/Api/Router.php @@ -302,6 +302,10 @@ final class Router Http::json(['data' => $this->saveMinerOffer($projectKey, Http::input())], 201); } + if ($resource === 'offer-preview' && $method === 'POST') { + Http::json(['data' => $this->offerPreview($projectKey, Http::input())]); + } + if ($resource === 'purchased-miners' && $method === 'GET') { Http::json(['data' => $this->purchasedMiners($projectKey)]); } @@ -310,9 +314,9 @@ final class Router Http::json(['data' => $this->updatePurchasedMiner($projectKey, (int) $matches[1], Http::input())]); } - if (preg_match('~^miner-offers/(\d+)/purchase$~', $resource, $matches) && $method === 'POST') { + if (preg_match('~^miner-offers/([^/]+)/purchase$~', $resource, $matches) && $method === 'POST') { $this->repository()->ensureProject($projectKey); - Http::json(['data' => $this->purchaseMiner($projectKey, (int) $matches[1], Http::input())], 201); + Http::json(['data' => $this->purchaseMiner($projectKey, rawurldecode((string) $matches[1]), Http::input())], 201); } throw new ApiException('Ressource nicht gefunden.', 404, ['resource' => $resource, 'method' => $method]); @@ -1645,7 +1649,59 @@ final class Router private function minerOffers(string $projectKey): array { - return $this->repository()->listMinerOffers($projectKey); + $settings = $this->settings($projectKey, [ + 'cost_plans' => true, + 'currencies' => false, + 'payouts' => true, + 'wallet_withdrawals' => true, + 'miner_offers' => false, + 'purchased_miners' => true, + ]); + $measurements = $this->measurements($projectKey, $settings); + $summary = $this->analytics()->buildSummary($measurements, $settings, [], [ + 'include_offer_scenarios' => true, + 'include_long_term_projection' => true, + ]); + return is_array($summary['miner_offers'] ?? null) ? $summary['miner_offers'] : []; + } + + private function offerPreview(string $projectKey, array $input): array + { + $settings = $this->settings($projectKey, [ + 'cost_plans' => true, + 'currencies' => false, + 'payouts' => true, + 'wallet_withdrawals' => true, + 'miner_offers' => false, + 'purchased_miners' => true, + ]); + + $settings['crypto_base_price_amount'] = $this->optionalDecimal($input['crypto_base_price_amount'] ?? null); + $settings['crypto_base_price_currency'] = $this->requiredCurrency($input['crypto_base_price_currency'] ?? 'USD', 'crypto_base_price_currency'); + $settings['fiat_base_price_amount'] = $this->optionalDecimal($input['fiat_base_price_amount'] ?? null); + + if ($settings['crypto_base_price_amount'] !== null) { + if ($settings['crypto_base_price_amount'] <= 0) { + throw new ApiException('crypto_base_price_amount muss groesser als 0 sein.', 422, ['field' => 'crypto_base_price_amount']); + } + if (!in_array($settings['crypto_base_price_currency'], ['USD', $settings['crypto_currency']], true)) { + throw new ApiException('crypto_base_price_currency muss USD oder die Projekt-Kryptowaehrung sein.', 422, ['field' => 'crypto_base_price_currency']); + } + } + + if ($settings['fiat_base_price_amount'] !== null && $settings['fiat_base_price_amount'] <= 0) { + throw new ApiException('fiat_base_price_amount muss groesser als 0 sein.', 422, ['field' => 'fiat_base_price_amount']); + } + + $measurements = $this->measurements($projectKey, $settings); + $summary = $this->analytics()->buildSummary($measurements, $settings, [], [ + 'include_offer_scenarios' => true, + 'include_long_term_projection' => true, + ]); + + return [ + 'miner_offers' => is_array($summary['miner_offers'] ?? null) ? $summary['miner_offers'] : [], + ]; } private function purchasedMiners(string $projectKey): array @@ -1905,56 +1961,17 @@ 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']; - $projectCryptoCurrency = $this->requiredCurrency($this->settings($projectKey, [ - 'cost_plans' => false, - 'currencies' => false, - 'payouts' => false, - 'wallet_withdrawals' => false, - 'miner_offers' => false, - 'purchased_miners' => false, - ])['crypto_currency'] ?? 'DOGE', 'crypto_currency'); - $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' => 3, - 'mining_speed_value' => $baseSpeed['value'], - 'mining_speed_unit' => $baseSpeed['unit'], - 'bonus_speed_value' => null, - 'bonus_speed_unit' => null, - 'bonus_percent' => null, - 'base_price_amount' => $this->requiredDecimal($input['base_price_amount'] ?? ($input['reference_price_amount'] ?? $input['price_amount'] ?? null), 'base_price_amount'), - '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, - ]; - - if ($paymentType === 'crypto') { - $allowedCryptoCurrencies = array_values(array_unique(['USD', $projectCryptoCurrency])); - if (!in_array($payload['base_price_currency'], $allowedCryptoCurrencies, true)) { - throw new ApiException('Bei Krypto-Angeboten sind nur USD oder die Projekt-Kryptowaehrung erlaubt.', 422, [ - 'field' => 'base_price_currency', - 'allowed' => $allowedCryptoCurrencies, - ]); - } - } else { - if ($payload['base_price_currency'] !== 'EUR') { - throw new ApiException('Bei FIAT-Angeboten ist aktuell nur EUR als Basiswaehrung erlaubt.', 422, [ - 'field' => 'base_price_currency', - 'allowed' => ['EUR'], - ]); - } - } - - return $this->repository()->saveMinerOffer($projectKey, $payload); + throw new ApiException( + 'Basis-Angebote werden nicht mehr in der Datenbank gespeichert. Bitte die Basispreise direkt im Bereich Miner-Angebote eingeben.', + 410 + ); } - private function purchaseMiner(string $projectKey, int $offerId, array $input): array + private function purchaseMiner(string $projectKey, string $offerId, array $input): array { - $offer = $this->repository()->getMinerOffer($projectKey, $offerId); + $offer = is_array($input['computed_offer'] ?? null) + ? $this->normalizeComputedMinerOffer((array) $input['computed_offer'], $offerId) + : $this->findComputedMinerOffer($projectKey, $offerId); if (!is_array($offer)) { throw new ApiException('Miner-Angebot nicht gefunden.', 404); } @@ -2001,7 +2018,7 @@ final class Router ? $this->requiredDateTime($input['purchased_at'], 'purchased_at', $this->projectTimezone($projectKey)) : $this->currentTimestamp(); - return $this->repository()->purchaseMiner($projectKey, $offerId, [ + return $this->repository()->purchaseMiner($projectKey, null, [ 'purchased_at' => $purchasedAt, 'label' => $this->optionalString($input['label'] ?? ($offer['label'] ?? null), 120) ?? $offer['label'], 'runtime_months' => $offer['runtime_months'] ?? null, @@ -2031,16 +2048,6 @@ final class Router throw new ApiException('Es kann aktuell nur auto_renew geaendert werden.', 422, ['field' => 'auto_renew']); } - $offerId = is_numeric($miner['miner_offer_id'] ?? null) ? (int) $miner['miner_offer_id'] : null; - if ($offerId === null) { - throw new ApiException('Dieser Miner kann nicht ueber ein Angebot aktualisiert werden.', 422); - } - - $offer = $this->repository()->getMinerOffer($projectKey, $offerId); - if (!is_array($offer) || empty($offer['auto_renew'])) { - throw new ApiException('Dieser Miner unterstuetzt keine automatische Verlaengerung.', 422); - } - return $this->repository()->updatePurchasedMinerAutoRenew( $projectKey, $minerId, @@ -2048,6 +2055,91 @@ final class Router ); } + private function findComputedMinerOffer(string $projectKey, string $offerId): ?array + { + foreach ($this->minerOffers($projectKey) as $offer) { + if ((string) ($offer['id'] ?? '') === $offerId) { + return $offer; + } + } + + return null; + } + + private function normalizeComputedMinerOffer(array $offer, string $offerId): array + { + $resolvedOfferId = trim((string) ($offer['id'] ?? '')); + if ($resolvedOfferId !== '' && $resolvedOfferId !== $offerId) { + throw new ApiException('computed_offer passt nicht zur angeforderten Offer-ID.', 422, [ + 'field' => 'computed_offer.id', + 'expected' => $offerId, + 'actual' => $resolvedOfferId, + ]); + } + + $paymentType = $this->enumValue($offer['payment_type'] ?? 'fiat', ['fiat', 'crypto'], 'computed_offer.payment_type'); + $runtimeMonths = $this->requiredPositiveInt($offer['runtime_months'] ?? null, 'computed_offer.runtime_months'); + $miningSpeedValue = $this->optionalDecimal($offer['mining_speed_value'] ?? null); + $miningSpeedUnit = $this->optionalSpeedUnit($offer['mining_speed_unit'] ?? null); + if (($miningSpeedValue === null) xor ($miningSpeedUnit === null)) { + throw new ApiException('computed_offer muss Mining-Geschwindigkeit und Einheit gemeinsam enthalten.', 422, [ + 'field' => 'computed_offer.mining_speed_value', + ]); + } + + $bonusPercent = $this->optionalDecimal($offer['bonus_percent'] ?? null); + if ($bonusPercent !== null && $bonusPercent < 0) { + throw new ApiException('computed_offer.bonus_percent darf nicht negativ sein.', 422, [ + 'field' => 'computed_offer.bonus_percent', + ]); + } + + $bonusSpeedValue = $this->optionalDecimal($offer['bonus_speed_value'] ?? null); + $bonusSpeedUnit = $this->optionalSpeedUnit($offer['bonus_speed_unit'] ?? null); + if (($bonusSpeedValue === null) xor ($bonusSpeedUnit === null)) { + throw new ApiException('computed_offer muss Bonus-Hashrate und Einheit gemeinsam enthalten.', 422, [ + 'field' => 'computed_offer.bonus_speed_value', + ]); + } + + $basePriceAmount = $this->optionalDecimal($offer['base_price_amount'] ?? null); + $basePriceCurrency = $this->optionalCurrency($offer['base_price_currency'] ?? null); + $effectivePriceAmount = $this->optionalDecimal($offer['effective_price_amount'] ?? null); + $effectivePriceCurrency = $this->optionalCurrency($offer['effective_price_currency'] ?? ($offer['price_currency'] ?? null)); + $referencePriceAmount = $this->optionalDecimal($offer['reference_price_amount'] ?? $basePriceAmount); + $referencePriceCurrency = $this->optionalCurrency($offer['reference_price_currency'] ?? $basePriceCurrency); + $usdReferenceAmount = $this->optionalDecimal($offer['usd_reference_amount'] ?? null); + + if ($basePriceAmount === null && $effectivePriceAmount === null) { + throw new ApiException('computed_offer muss mindestens einen Preis enthalten.', 422, [ + 'field' => 'computed_offer.effective_price_amount', + ]); + } + + return array_merge($offer, [ + 'id' => $resolvedOfferId !== '' ? $resolvedOfferId : $offerId, + 'label' => $this->optionalString($offer['label'] ?? null, 120), + 'payment_type' => $paymentType, + 'runtime_months' => $runtimeMonths, + 'mining_speed_value' => $miningSpeedValue, + 'mining_speed_unit' => $miningSpeedUnit, + 'bonus_percent' => $bonusPercent, + 'bonus_speed_value' => $bonusSpeedValue, + 'bonus_speed_unit' => $bonusSpeedUnit, + 'base_price_amount' => $basePriceAmount, + 'base_price_currency' => $basePriceCurrency, + 'effective_price_amount' => $effectivePriceAmount, + 'effective_price_currency' => $effectivePriceCurrency, + 'price_currency' => $effectivePriceCurrency, + 'reference_price_amount' => $referencePriceAmount, + 'reference_price_currency' => $referencePriceCurrency, + 'usd_reference_amount' => $usdReferenceAmount, + 'auto_renew' => !empty($offer['auto_renew']), + 'is_active' => !array_key_exists('is_active', $offer) || !empty($offer['is_active']), + 'note' => $this->optionalString($offer['note'] ?? null, 1000), + ]); + } + private function saveDashboard(string $projectKey, array $input): array { $payload = [ diff --git a/custom/apps/mining-checker/src/Domain/AnalyticsService.php b/custom/apps/mining-checker/src/Domain/AnalyticsService.php index 2ae0181e..71233300 100644 --- a/custom/apps/mining-checker/src/Domain/AnalyticsService.php +++ b/custom/apps/mining-checker/src/Domain/AnalyticsService.php @@ -1003,7 +1003,7 @@ final class AnalyticsService private function expandOfferVariants(array $offers, array $settings): array { $expanded = []; - foreach ($offers as $offer) { + foreach ($this->configuredOfferBases($settings) as $offer) { $paymentType = $this->normalizeOfferPaymentType($offer); $baseSpeed = self::BASE_OFFER_SPEEDS[$paymentType] ?? self::BASE_OFFER_SPEEDS['fiat']; $baseSpeedValue = (float) $baseSpeed['value']; @@ -1089,6 +1089,39 @@ final class AnalyticsService return $expanded; } + private function configuredOfferBases(array $settings): array + { + $bases = []; + $cryptoAmount = is_numeric($settings['crypto_base_price_amount'] ?? null) ? (float) $settings['crypto_base_price_amount'] : null; + $cryptoCurrency = strtoupper(trim((string) ($settings['crypto_base_price_currency'] ?? ''))); + if ($cryptoAmount !== null && $cryptoAmount > 0 && $cryptoCurrency !== '') { + $bases[] = [ + 'id' => 'computed-crypto-base', + 'label' => 'Crypto Server', + 'payment_type' => 'crypto', + 'base_price_amount' => $cryptoAmount, + 'base_price_currency' => $cryptoCurrency, + 'auto_renew' => 0, + 'is_active' => 1, + ]; + } + + $fiatAmount = is_numeric($settings['fiat_base_price_amount'] ?? null) ? (float) $settings['fiat_base_price_amount'] : null; + if ($fiatAmount !== null && $fiatAmount > 0) { + $bases[] = [ + 'id' => 'computed-fiat-base', + 'label' => 'Fiat Server', + 'payment_type' => 'fiat', + 'base_price_amount' => $fiatAmount, + 'base_price_currency' => 'EUR', + 'auto_renew' => 1, + 'is_active' => 1, + ]; + } + + return $bases; + } + private function normalizeOfferPaymentType(array $offer): string { $paymentType = strtolower(trim((string) ($offer['payment_type'] ?? ''))); diff --git a/custom/apps/mining-checker/src/Infrastructure/MiningRepository.php b/custom/apps/mining-checker/src/Infrastructure/MiningRepository.php index 2f6d13c5..383a84d8 100644 --- a/custom/apps/mining-checker/src/Infrastructure/MiningRepository.php +++ b/custom/apps/mining-checker/src/Infrastructure/MiningRepository.php @@ -832,7 +832,7 @@ final class MiningRepository return is_array($row) ? $this->normalizeRow($row) : null; } - public function purchaseMiner(string $projectKey, int $offerId, array $payload): array + public function purchaseMiner(string $projectKey, ?int $offerId, array $payload): array { if ($this->driver === 'pgsql') { $stmt = $this->pdo->prepare( @@ -1430,7 +1430,7 @@ final class MiningRepository ]; } - private function normalizePurchasedPayload(string $projectKey, int $offerId, array $payload): array + private function normalizePurchasedPayload(string $projectKey, ?int $offerId, array $payload): array { return [ 'project_key' => $projectKey,