*/ public function availablePlans(): \Illuminate\Database\Eloquent\Collection { return HostingProduct::query() ->active() ->visible() ->where('category', HostingProduct::CATEGORY_SHARED) ->ordered() ->get(); } /** * @return array */ 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} */ 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> */ 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); } } }