Files
ladill-hosting/app/Services/Hosting/HostingPricingService.php
T
isaaccladandCursor e251a4cf60
Deploy Ladill Hosting / deploy (push) Failing after 17s
Initial Ladill Hosting app with Gitea deploy pipeline.
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>
2026-06-06 16:24:20 +00:00

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;
}
}