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>
323 lines
14 KiB
PHP
323 lines
14 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Hosting;
|
|
|
|
use App\Models\CustomerHostingOrder;
|
|
use App\Models\HostingAccount;
|
|
use App\Models\HostingBillingInvoice;
|
|
use App\Models\HostingPlanChange;
|
|
use App\Models\HostingProduct;
|
|
use App\Models\User;
|
|
use App\Services\Hosting\Providers\SharedNodeProvider;
|
|
use Illuminate\Support\Facades\DB;
|
|
use Illuminate\Validation\ValidationException;
|
|
|
|
class HostingPlanChangeService
|
|
{
|
|
public function __construct(
|
|
private NodeCapacityService $capacityService,
|
|
private HostingResourcePolicyService $resourcePolicyService,
|
|
private SharedNodeProvider $sharedNodeProvider,
|
|
) {
|
|
}
|
|
|
|
/**
|
|
* @return \Illuminate\Database\Eloquent\Collection<int, HostingProduct>
|
|
*/
|
|
public function availablePlans(): \Illuminate\Database\Eloquent\Collection
|
|
{
|
|
return HostingProduct::query()
|
|
->active()
|
|
->visible()
|
|
->where('category', HostingProduct::CATEGORY_SHARED)
|
|
->ordered()
|
|
->get();
|
|
}
|
|
|
|
/**
|
|
* @return array<string, mixed>
|
|
*/
|
|
public function quote(HostingAccount $account, HostingProduct $targetProduct): array
|
|
{
|
|
$account->loadMissing(['product', 'latestOrder', 'sites', 'databases', 'node']);
|
|
$this->assertCanChange($account, $targetProduct);
|
|
|
|
$currentProduct = $account->product;
|
|
$order = $account->latestOrder;
|
|
$billingCycle = $order?->billing_cycle ?? CustomerHostingOrder::CYCLE_MONTHLY;
|
|
$cycleMonths = $order?->getCycleLengthInMonths() ?? 1;
|
|
$currentCyclePrice = $this->cyclePriceFor($currentProduct, $billingCycle);
|
|
$targetCyclePrice = $this->cyclePriceFor($targetProduct, $billingCycle);
|
|
$remaining = $this->remainingCycleState($account, $order, $cycleMonths);
|
|
$priceDelta = round($targetCyclePrice - $currentCyclePrice, 2);
|
|
$direction = $priceDelta >= 0
|
|
? HostingPlanChange::DIRECTION_UPGRADE
|
|
: HostingPlanChange::DIRECTION_DOWNGRADE;
|
|
$proratedDelta = round($priceDelta * $remaining['ratio'], 2);
|
|
$chargeAmount = $proratedDelta > 0 ? $proratedDelta : 0;
|
|
$creditAmount = $proratedDelta < 0 ? abs($proratedDelta) : 0;
|
|
|
|
return [
|
|
'direction' => $direction,
|
|
'billing_cycle' => $billingCycle,
|
|
'cycle_months' => $cycleMonths,
|
|
'remaining_days' => $remaining['days'],
|
|
'proration_ratio' => $remaining['ratio'],
|
|
'current_cycle_price' => $currentCyclePrice,
|
|
'target_cycle_price' => $targetCyclePrice,
|
|
'charge_amount' => $chargeAmount,
|
|
'credit_amount' => $creditAmount,
|
|
'net_amount' => round($chargeAmount - $creditAmount, 2),
|
|
'currency' => $targetProduct->display_currency,
|
|
'line_items' => $this->lineItems($account, $targetProduct, $direction, $chargeAmount, $creditAmount, $remaining['ratio']),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array{change: HostingPlanChange, invoice: HostingBillingInvoice, account: HostingAccount, quote: array<string, mixed>}
|
|
*/
|
|
public function apply(HostingAccount $account, HostingProduct $targetProduct, User $actor): array
|
|
{
|
|
$account->loadMissing(['product', 'latestOrder', 'sites', 'databases', 'node']);
|
|
$quote = $this->quote($account, $targetProduct);
|
|
$currentProduct = $account->product;
|
|
|
|
return DB::transaction(function () use ($account, $targetProduct, $actor, $quote, $currentProduct): array {
|
|
$change = HostingPlanChange::query()->create([
|
|
'user_id' => $actor->id,
|
|
'hosting_account_id' => $account->id,
|
|
'from_hosting_product_id' => $currentProduct->id,
|
|
'to_hosting_product_id' => $targetProduct->id,
|
|
'direction' => $quote['direction'],
|
|
'billing_cycle' => $quote['billing_cycle'],
|
|
'cycle_months' => $quote['cycle_months'],
|
|
'remaining_days' => $quote['remaining_days'],
|
|
'proration_ratio' => $quote['proration_ratio'],
|
|
'old_cycle_price' => $quote['current_cycle_price'],
|
|
'new_cycle_price' => $quote['target_cycle_price'],
|
|
'charge_amount' => $quote['charge_amount'],
|
|
'credit_amount' => $quote['credit_amount'],
|
|
'net_amount' => $quote['net_amount'],
|
|
'currency' => $quote['currency'],
|
|
'status' => 'applied',
|
|
'applied_at' => now(),
|
|
'metadata' => [
|
|
'actor_id' => $actor->id,
|
|
'sites_count' => $account->sites->count(),
|
|
'databases_count' => $account->databases->count(),
|
|
],
|
|
]);
|
|
|
|
$invoiceStatus = $quote['charge_amount'] > 0
|
|
? HostingBillingInvoice::STATUS_PENDING
|
|
: ($quote['credit_amount'] > 0
|
|
? HostingBillingInvoice::STATUS_CREDITED
|
|
: HostingBillingInvoice::STATUS_WAIVED);
|
|
|
|
$invoice = HostingBillingInvoice::query()->create([
|
|
'user_id' => $actor->id,
|
|
'hosting_account_id' => $account->id,
|
|
'hosting_plan_change_id' => $change->id,
|
|
'invoice_type' => HostingBillingInvoice::TYPE_PLAN_CHANGE,
|
|
'status' => $invoiceStatus,
|
|
'currency' => $quote['currency'],
|
|
'subtotal_amount' => $quote['charge_amount'],
|
|
'credit_amount' => $quote['credit_amount'],
|
|
'total_amount' => max($quote['net_amount'], 0),
|
|
'description' => sprintf(
|
|
'%s %s from %s to %s',
|
|
ucfirst($quote['direction']),
|
|
$account->primary_domain ?: $account->username,
|
|
$currentProduct->name,
|
|
$targetProduct->name
|
|
),
|
|
'paid_at' => $invoiceStatus !== HostingBillingInvoice::STATUS_PENDING ? now() : null,
|
|
'line_items' => $quote['line_items'],
|
|
'metadata' => [
|
|
'direction' => $quote['direction'],
|
|
'billing_cycle' => $quote['billing_cycle'],
|
|
'remaining_days' => $quote['remaining_days'],
|
|
],
|
|
]);
|
|
|
|
$this->applyProductToAccount($account, $targetProduct, $change, $actor);
|
|
|
|
return [
|
|
'change' => $change->fresh(['fromProduct', 'toProduct']),
|
|
'invoice' => $invoice->fresh(),
|
|
'account' => $account->fresh(['product', 'node']),
|
|
'quote' => $quote,
|
|
];
|
|
});
|
|
}
|
|
|
|
private function assertCanChange(HostingAccount $account, HostingProduct $targetProduct): void
|
|
{
|
|
if (! $account->product || ! $account->product->isSharedHosting()) {
|
|
throw ValidationException::withMessages([
|
|
'hosting_account_id' => 'Only shared hosting accounts can change packages.',
|
|
]);
|
|
}
|
|
|
|
if (! $targetProduct->isSharedHosting() || ! $targetProduct->is_active || ! $targetProduct->is_visible) {
|
|
throw ValidationException::withMessages([
|
|
'target_product_id' => 'Selected plan is not available for hosting upgrades.',
|
|
]);
|
|
}
|
|
|
|
if ((int) $account->hosting_product_id === (int) $targetProduct->id) {
|
|
throw ValidationException::withMessages([
|
|
'target_product_id' => 'Account is already on that plan.',
|
|
]);
|
|
}
|
|
|
|
$siteLimit = (int) ($targetProduct->max_domains ?? 0);
|
|
if ($siteLimit >= 0 && $account->sites->count() > $siteLimit) {
|
|
throw ValidationException::withMessages([
|
|
'target_product_id' => "The target plan supports {$siteLimit} site(s), but this account already has {$account->sites->count()}.",
|
|
]);
|
|
}
|
|
|
|
$databaseLimit = $targetProduct->max_databases;
|
|
if ($databaseLimit !== null && $databaseLimit >= 0 && $account->databases->count() > $databaseLimit) {
|
|
throw ValidationException::withMessages([
|
|
'target_product_id' => "The target plan supports {$databaseLimit} database(s), but this account already has {$account->databases->count()}.",
|
|
]);
|
|
}
|
|
|
|
$diskDelta = max((int) ($targetProduct->disk_gb ?? 0) - (int) ($account->allocated_disk_gb ?? 0), 0);
|
|
if ($diskDelta > 0 && $account->node && ! $this->capacityService->canProvision($account->node, $diskDelta)) {
|
|
throw ValidationException::withMessages([
|
|
'target_product_id' => 'The assigned server does not have enough logical capacity for this upgrade.',
|
|
]);
|
|
}
|
|
}
|
|
|
|
private function cyclePriceFor(?HostingProduct $product, string $billingCycle): float
|
|
{
|
|
if (! $product) {
|
|
return 0;
|
|
}
|
|
|
|
$price = $product->getPriceForCycle($billingCycle);
|
|
|
|
if ($price === null) {
|
|
$price = $product->getPriceForCycle(CustomerHostingOrder::CYCLE_MONTHLY);
|
|
}
|
|
|
|
return round((float) $price, 2);
|
|
}
|
|
|
|
/**
|
|
* @return array{ratio: float, days: int}
|
|
*/
|
|
private function remainingCycleState(HostingAccount $account, ?CustomerHostingOrder $order, int $cycleMonths): array
|
|
{
|
|
$periodEnd = $account->expires_at
|
|
?? $order?->expires_at
|
|
?? now()->copy()->addMonthsNoOverflow($cycleMonths);
|
|
|
|
if (! $periodEnd || $periodEnd->lte(now())) {
|
|
return ['ratio' => 1.0, 'days' => 0];
|
|
}
|
|
|
|
$periodStart = $periodEnd->copy()->subMonthsNoOverflow(max($cycleMonths, 1));
|
|
$totalSeconds = max($periodStart->diffInSeconds($periodEnd, false), 1);
|
|
$remainingSeconds = max(now()->diffInSeconds($periodEnd, false), 0);
|
|
|
|
return [
|
|
'ratio' => round(min(max($remainingSeconds / $totalSeconds, 0), 1), 4),
|
|
'days' => (int) ceil($remainingSeconds / 86400),
|
|
];
|
|
}
|
|
|
|
/**
|
|
* @return array<int, array<string, mixed>>
|
|
*/
|
|
private function lineItems(
|
|
HostingAccount $account,
|
|
HostingProduct $targetProduct,
|
|
string $direction,
|
|
float $chargeAmount,
|
|
float $creditAmount,
|
|
float $ratio
|
|
): array {
|
|
$currentProduct = $account->product;
|
|
|
|
return array_values(array_filter([
|
|
[
|
|
'code' => 'plan_change',
|
|
'label' => sprintf('%s to %s', ucfirst($direction), $targetProduct->name),
|
|
'description' => sprintf('Plan change from %s to %s.', $currentProduct?->name ?? 'Current plan', $targetProduct->name),
|
|
'amount' => round($chargeAmount, 2),
|
|
],
|
|
$creditAmount > 0 ? [
|
|
'code' => 'proration_credit',
|
|
'label' => 'Unused time credit',
|
|
'description' => sprintf('Prorated credit at %.2f%% of the current cycle.', $ratio * 100),
|
|
'amount' => round($creditAmount * -1, 2),
|
|
] : null,
|
|
]));
|
|
}
|
|
|
|
private function applyProductToAccount(HostingAccount $account, HostingProduct $targetProduct, HostingPlanChange $change, User $actor): void
|
|
{
|
|
$policyLimits = $this->resourcePolicyService->defaultLimitsForProduct($targetProduct);
|
|
$resourceLimits = array_merge((array) ($account->resource_limits ?? []), [
|
|
'disk_gb' => (int) ($targetProduct->disk_gb ?? 0),
|
|
'bandwidth_gb' => $targetProduct->bandwidth_gb,
|
|
'max_domains' => $targetProduct->max_domains,
|
|
'max_databases' => $targetProduct->max_databases,
|
|
'max_email_accounts' => $targetProduct->max_email_accounts,
|
|
'max_ftp_accounts' => $targetProduct->max_ftp_accounts,
|
|
], $policyLimits);
|
|
|
|
$metadata = array_merge((array) ($account->metadata ?? []), [
|
|
'last_plan_change_id' => $change->id,
|
|
'last_plan_change_at' => now()->toIso8601String(),
|
|
'last_plan_changed_by' => $actor->id,
|
|
]);
|
|
|
|
$account->update([
|
|
'hosting_product_id' => $targetProduct->id,
|
|
'allocated_disk_gb' => (int) ($targetProduct->disk_gb ?? 0),
|
|
'cpu_limit_percent' => $policyLimits['cpu_limit_percent'] ?? $account->cpu_limit_percent,
|
|
'memory_limit_mb' => $policyLimits['memory_limit_mb'] ?? $account->memory_limit_mb,
|
|
'process_limit' => $policyLimits['process_limit'] ?? $account->process_limit,
|
|
'io_limit_mb' => $policyLimits['io_limit_mb'] ?? $account->io_limit_mb,
|
|
'inode_limit' => $policyLimits['inode_limit'] ?? $account->inode_limit,
|
|
'resource_limits' => $resourceLimits,
|
|
'metadata' => $metadata,
|
|
]);
|
|
|
|
$account->refresh();
|
|
|
|
$activeOrder = CustomerHostingOrder::query()
|
|
->where('hosting_account_id', $account->id)
|
|
->whereIn('status', [
|
|
CustomerHostingOrder::STATUS_ACTIVE,
|
|
CustomerHostingOrder::STATUS_SUSPENDED,
|
|
CustomerHostingOrder::STATUS_PROVISIONING,
|
|
])
|
|
->latest('id')
|
|
->first();
|
|
|
|
if ($activeOrder) {
|
|
$activeOrder->update([
|
|
'hosting_product_id' => $targetProduct->id,
|
|
'currency' => $targetProduct->display_currency,
|
|
'meta' => array_merge((array) ($activeOrder->meta ?? []), [
|
|
'last_plan_change_id' => $change->id,
|
|
'last_plan_change_direction' => $change->direction,
|
|
]),
|
|
]);
|
|
}
|
|
|
|
$this->sharedNodeProvider->syncAccountPackage($account);
|
|
|
|
if ($account->node) {
|
|
$this->capacityService->refreshNodeResourceTotals($account->node);
|
|
}
|
|
}
|
|
}
|