Files
ladill-hosting/app/Services/Hosting/HostingPlanChangeService.php
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

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