Extract Ladill Servers as standalone app at servers.ladill.com.
VPS and dedicated server ordering, managed panels, SSO, billing, and launcher integration — forked from hosting infrastructure with server-focused routes and dashboard. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -0,0 +1,242 @@
|
||||
<?php
|
||||
|
||||
namespace App\Services\Hosting;
|
||||
|
||||
use App\Models\HostingProduct;
|
||||
use App\Services\ExchangeRateService;
|
||||
use App\Services\Hosting\Providers\ContaboProvider;
|
||||
use Illuminate\Support\Facades\Cache;
|
||||
use Illuminate\Support\Facades\Log;
|
||||
|
||||
class HostingPricingService
|
||||
{
|
||||
private array $margins;
|
||||
private array $contaboBasePrices;
|
||||
private ExchangeRateService $exchangeRateService;
|
||||
private ContaboProvider $contaboProvider;
|
||||
|
||||
public function __construct(ExchangeRateService $exchangeRateService, ContaboProvider $contaboProvider)
|
||||
{
|
||||
$this->exchangeRateService = $exchangeRateService;
|
||||
$this->contaboProvider = $contaboProvider;
|
||||
$this->margins = config('hosting.pricing.margins', [
|
||||
'vps' => 30,
|
||||
'dedicated' => 30,
|
||||
]);
|
||||
$this->contaboBasePrices = config('hosting.pricing.contabo_base_prices', []);
|
||||
}
|
||||
|
||||
public function getExchangeRate(): float
|
||||
{
|
||||
return $this->exchangeRateService->getUsdToGhs();
|
||||
}
|
||||
|
||||
public function getMargin(string $category): float
|
||||
{
|
||||
return (float) ($this->margins[$category] ?? 30);
|
||||
}
|
||||
|
||||
public function getContaboBasePrice(?string $productId): ?array
|
||||
{
|
||||
if ($productId === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
$productId = trim($productId);
|
||||
|
||||
if ($productId === '') {
|
||||
return null;
|
||||
}
|
||||
|
||||
$cacheKey = "contabo_product_price_{$productId}";
|
||||
|
||||
return Cache::remember($cacheKey, (int) config('hosting.pricing.contabo_price_cache_ttl', 3600), function () use ($productId) {
|
||||
$liveProduct = $this->getLiveContaboProduct($productId);
|
||||
|
||||
if ($liveProduct !== null) {
|
||||
return $liveProduct + ['source' => 'contabo_api'];
|
||||
}
|
||||
|
||||
$fallback = $this->contaboBasePrices[$productId] ?? null;
|
||||
|
||||
return is_array($fallback) ? $fallback + ['source' => 'local_fallback'] : null;
|
||||
});
|
||||
}
|
||||
|
||||
public function calculatePrice(float $basePriceUsd, string $category): float
|
||||
{
|
||||
$margin = $this->getMargin($category);
|
||||
$exchangeRate = $this->getExchangeRate();
|
||||
$convertedPrice = $basePriceUsd * $exchangeRate;
|
||||
$priceWithMargin = $convertedPrice * (1 + ($margin / 100));
|
||||
|
||||
// Round to nearest 5 for cleaner pricing
|
||||
return round($priceWithMargin / 5) * 5;
|
||||
}
|
||||
|
||||
public function calculateQuarterlyPrice(float $monthlyPrice): float
|
||||
{
|
||||
return $this->calculateTermPrice($monthlyPrice, 3, 'quarterly');
|
||||
}
|
||||
|
||||
public function calculateSemiannualPrice(float $monthlyPrice): float
|
||||
{
|
||||
return $this->calculateTermPrice($monthlyPrice, 6, 'semiannual');
|
||||
}
|
||||
|
||||
public function calculateYearlyPrice(float $monthlyPrice): float
|
||||
{
|
||||
return $this->calculateTermPrice($monthlyPrice, 12, 'yearly');
|
||||
}
|
||||
|
||||
public function convertUsdRecurringOption(float $monthlyUsd, string $category): float
|
||||
{
|
||||
$margin = $this->getMargin($category);
|
||||
$exchangeRate = $this->getExchangeRate();
|
||||
|
||||
return round($monthlyUsd * $exchangeRate * (1 + ($margin / 100)), 2);
|
||||
}
|
||||
|
||||
private function calculateTermPrice(float $monthlyPrice, int $months, string $cycle): float
|
||||
{
|
||||
$discountPercent = (float) config("hosting.pricing.term_discounts.{$cycle}", 0);
|
||||
$total = $monthlyPrice * $months * (1 - ($discountPercent / 100));
|
||||
|
||||
return round($total / 5) * 5;
|
||||
}
|
||||
|
||||
public function getDynamicPriceForProduct(HostingProduct $product): array
|
||||
{
|
||||
// Only calculate dynamic pricing for VPS and Dedicated
|
||||
if (!in_array($product->category, ['vps', 'dedicated'])) {
|
||||
return [
|
||||
'monthly' => (float) $product->price_monthly,
|
||||
'quarterly' => (float) $product->price_quarterly,
|
||||
'semiannual' => null,
|
||||
'yearly' => (float) $product->price_yearly,
|
||||
'currency' => $product->display_currency,
|
||||
'is_dynamic' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$exchangeRate = $this->getExchangeRate();
|
||||
$cacheKey = "hosting_price_{$product->slug}_{$product->contabo_product_id}_{$exchangeRate}";
|
||||
|
||||
return Cache::remember($cacheKey, 3600, function () use ($product, $exchangeRate) {
|
||||
$contaboProduct = $this->getContaboBasePrice($product->contabo_product_id);
|
||||
|
||||
if (!$contaboProduct) {
|
||||
// Fallback to stored prices if no Contabo mapping
|
||||
return [
|
||||
'monthly' => (float) $product->price_monthly,
|
||||
'quarterly' => (float) $product->price_quarterly,
|
||||
'semiannual' => null,
|
||||
'yearly' => (float) $product->price_yearly,
|
||||
'currency' => $product->display_currency,
|
||||
'is_dynamic' => false,
|
||||
];
|
||||
}
|
||||
|
||||
$basePriceUsd = $contaboProduct['monthly'];
|
||||
$monthlyPrice = $this->calculatePrice($basePriceUsd, $product->category);
|
||||
|
||||
return [
|
||||
'monthly' => $monthlyPrice,
|
||||
'quarterly' => $this->calculateQuarterlyPrice($monthlyPrice),
|
||||
'semiannual' => $this->calculateSemiannualPrice($monthlyPrice),
|
||||
'yearly' => $this->calculateYearlyPrice($monthlyPrice),
|
||||
'currency' => 'GHS',
|
||||
'is_dynamic' => true,
|
||||
'base_price_usd' => $basePriceUsd,
|
||||
'exchange_rate' => $exchangeRate,
|
||||
'margin_percent' => $this->getMargin($product->category),
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
public function getPricingBreakdown(HostingProduct $product): array
|
||||
{
|
||||
if (!in_array($product->category, ['vps', 'dedicated'])) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$contaboProduct = $this->getContaboBasePrice($product->contabo_product_id);
|
||||
|
||||
if (!$contaboProduct) {
|
||||
return [];
|
||||
}
|
||||
|
||||
$basePriceUsd = $contaboProduct['monthly'];
|
||||
$margin = $this->getMargin($product->category);
|
||||
$exchangeRate = $this->getExchangeRate();
|
||||
$convertedPrice = $basePriceUsd * $exchangeRate;
|
||||
$marginAmount = $convertedPrice * ($margin / 100);
|
||||
$finalPrice = $this->calculatePrice($basePriceUsd, $product->category);
|
||||
|
||||
return [
|
||||
'contabo_price_usd' => $basePriceUsd,
|
||||
'price_source' => (string) ($contaboProduct['source'] ?? 'unknown'),
|
||||
'exchange_rate' => $exchangeRate,
|
||||
'converted_ghs' => round($convertedPrice, 2),
|
||||
'margin_percent' => $margin,
|
||||
'margin_amount_ghs' => round($marginAmount, 2),
|
||||
'final_price_ghs' => $finalPrice,
|
||||
'profit_per_month_ghs' => round($finalPrice - $convertedPrice, 2),
|
||||
];
|
||||
}
|
||||
|
||||
public function getExchangeRateInfo(): array
|
||||
{
|
||||
return $this->exchangeRateService->getRateInfo();
|
||||
}
|
||||
|
||||
public function refreshExchangeRate(): float
|
||||
{
|
||||
return $this->exchangeRateService->refreshRate();
|
||||
}
|
||||
|
||||
private function getLiveContaboProduct(string $productId): ?array
|
||||
{
|
||||
try {
|
||||
foreach ($this->contaboProvider->getAvailableProducts() as $product) {
|
||||
if ((string) ($product['id'] ?? '') !== $productId) {
|
||||
continue;
|
||||
}
|
||||
|
||||
$monthlyUsd = $this->extractMonthlyUsdPrice($product);
|
||||
|
||||
if ($monthlyUsd === null || $monthlyUsd <= 0) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return [
|
||||
'monthly' => $monthlyUsd,
|
||||
'name' => (string) ($product['name'] ?? $productId),
|
||||
'cpu' => isset($product['cpu']) ? (int) $product['cpu'] : null,
|
||||
'ram_gb' => isset($product['ram_gb']) ? (float) $product['ram_gb'] : null,
|
||||
'disk_gb' => isset($product['disk_gb']) ? (float) $product['disk_gb'] : null,
|
||||
];
|
||||
}
|
||||
} catch (\Throwable $exception) {
|
||||
Log::warning('Contabo product price lookup failed; falling back to configured prices.', [
|
||||
'product_id' => $productId,
|
||||
'message' => $exception->getMessage(),
|
||||
]);
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
private function extractMonthlyUsdPrice(array $product): ?float
|
||||
{
|
||||
foreach (['price_monthly', 'monthly', 'monthly_usd', 'monthlyUsd', 'monthlyPrice', 'price'] as $key) {
|
||||
if (! isset($product[$key]) || ! is_numeric($product[$key])) {
|
||||
continue;
|
||||
}
|
||||
|
||||
return (float) $product[$key];
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user