Initial Ladill Hosting app with Gitea deploy pipeline.
Deploy Ladill Hosting / deploy (push) Failing after 17s
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>
This commit is contained in:
@@ -0,0 +1,322 @@
|
||||
<?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);
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user