Deploy Ladill Hosting / deploy (push) Failing after 17s
Shared web hosting extracted from the platform monolith, with CI deploy to /var/www/ladill-hosting matching Bird/Domains/Email. Co-authored-by: Cursor <cursoragent@cursor.com>
243 lines
8.5 KiB
PHP
243 lines
8.5 KiB
PHP
<?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;
|
|
}
|
|
}
|