Deploy Ladill Hosting / deploy (push) Successful in 1m9s
Customers can create label-based subdomains under primary/addon sites with a separate max_domains×5 quota, nginx/docroot/SSL provisioning, and managed DNS A records when Ladill controls the parent zone. Co-authored-by: Cursor <cursoragent@cursor.com>
433 lines
12 KiB
PHP
433 lines
12 KiB
PHP
<?php
|
||
|
||
namespace App\Models;
|
||
|
||
use Carbon\CarbonInterface;
|
||
use Illuminate\Database\Eloquent\Collection;
|
||
use Illuminate\Database\Eloquent\Factories\HasFactory;
|
||
use Illuminate\Database\Eloquent\Relations\BelongsToMany;
|
||
use Illuminate\Database\Eloquent\Model;
|
||
use Illuminate\Database\Eloquent\Relations\BelongsTo;
|
||
use Illuminate\Database\Eloquent\Relations\HasMany;
|
||
use Illuminate\Database\Eloquent\Relations\HasOne;
|
||
use Illuminate\Database\Eloquent\SoftDeletes;
|
||
use Illuminate\Support\Facades\Schema;
|
||
|
||
class HostingAccount extends Model
|
||
{
|
||
use HasFactory, SoftDeletes;
|
||
|
||
public const TYPE_SHARED = 'shared';
|
||
public const TYPE_VPS = 'vps';
|
||
public const TYPE_DEDICATED = 'dedicated';
|
||
|
||
public const STATUS_PENDING = 'pending';
|
||
public const STATUS_PROVISIONING = 'provisioning';
|
||
public const STATUS_ACTIVE = 'active';
|
||
public const STATUS_SUSPENDED = 'suspended';
|
||
public const STATUS_TERMINATED = 'terminated';
|
||
public const STATUS_FAILED = 'failed';
|
||
|
||
public const RESOURCE_STATUS_ACTIVE = 'active';
|
||
public const RESOURCE_STATUS_THROTTLED = 'throttled';
|
||
public const RESOURCE_STATUS_SUSPENDED = 'suspended';
|
||
|
||
public const GRACE_PERIOD_MONTHS = 3;
|
||
|
||
protected $fillable = [
|
||
'platform_id',
|
||
'user_id',
|
||
'hosting_product_id',
|
||
'hosting_node_id',
|
||
'username',
|
||
'primary_domain',
|
||
'type',
|
||
'status',
|
||
'provider_account_id',
|
||
'home_directory',
|
||
'allocated_disk_gb',
|
||
'disk_used_bytes',
|
||
'inode_count',
|
||
'cpu_usage_percent',
|
||
'memory_used_mb',
|
||
'process_count',
|
||
'io_usage_mb',
|
||
'last_usage_sync_at',
|
||
'bandwidth_used_bytes',
|
||
'bandwidth_reset_at',
|
||
'php_version',
|
||
'cpu_limit_percent',
|
||
'memory_limit_mb',
|
||
'process_limit',
|
||
'io_limit_mb',
|
||
'inode_limit',
|
||
'resource_status',
|
||
'uploads_restricted',
|
||
'is_flagged',
|
||
'warning_count',
|
||
'cpu_breach_streak',
|
||
'memory_breach_streak',
|
||
'process_breach_streak',
|
||
'io_breach_streak',
|
||
'inode_breach_streak',
|
||
'last_warning_at',
|
||
'last_resource_breach_at',
|
||
'throttled_at',
|
||
'features_enabled',
|
||
'resource_limits',
|
||
'suspension_reason',
|
||
'suspended_at',
|
||
'provisioned_at',
|
||
'expires_at',
|
||
'metadata',
|
||
];
|
||
|
||
protected $casts = [
|
||
'features_enabled' => 'array',
|
||
'resource_limits' => 'array',
|
||
'metadata' => 'array',
|
||
'allocated_disk_gb' => 'integer',
|
||
'inode_count' => 'integer',
|
||
'cpu_usage_percent' => 'decimal:2',
|
||
'memory_used_mb' => 'integer',
|
||
'process_count' => 'integer',
|
||
'io_usage_mb' => 'decimal:2',
|
||
'cpu_limit_percent' => 'integer',
|
||
'memory_limit_mb' => 'integer',
|
||
'process_limit' => 'integer',
|
||
'io_limit_mb' => 'integer',
|
||
'inode_limit' => 'integer',
|
||
'uploads_restricted' => 'boolean',
|
||
'is_flagged' => 'boolean',
|
||
'warning_count' => 'integer',
|
||
'cpu_breach_streak' => 'integer',
|
||
'memory_breach_streak' => 'integer',
|
||
'process_breach_streak' => 'integer',
|
||
'io_breach_streak' => 'integer',
|
||
'inode_breach_streak' => 'integer',
|
||
'last_usage_sync_at' => 'datetime',
|
||
'last_warning_at' => 'datetime',
|
||
'last_resource_breach_at' => 'datetime',
|
||
'throttled_at' => 'datetime',
|
||
'bandwidth_reset_at' => 'datetime',
|
||
'suspended_at' => 'datetime',
|
||
'provisioned_at' => 'datetime',
|
||
'expires_at' => 'datetime',
|
||
];
|
||
|
||
public function user(): BelongsTo
|
||
{
|
||
return $this->belongsTo(User::class);
|
||
}
|
||
|
||
public function product(): BelongsTo
|
||
{
|
||
return $this->belongsTo(HostingProduct::class, 'hosting_product_id');
|
||
}
|
||
|
||
public function node(): BelongsTo
|
||
{
|
||
return $this->belongsTo(HostingNode::class, 'hosting_node_id');
|
||
}
|
||
|
||
public function sites(): HasMany
|
||
{
|
||
return $this->hasMany(HostedSite::class);
|
||
}
|
||
|
||
/** Primary label for UI lists — prefers linked hosted_sites over primary_domain. */
|
||
public function linkedDomainLabel(): ?string
|
||
{
|
||
if ($this->relationLoaded('sites') && $this->sites->isNotEmpty()) {
|
||
$domains = $this->sites->pluck('domain')->filter()->unique()->values();
|
||
|
||
if ($domains->count() === 1) {
|
||
return (string) $domains->first();
|
||
}
|
||
|
||
$preview = $domains->take(2)->implode(', ');
|
||
|
||
return $domains->count() > 2
|
||
? "{$preview} +".($domains->count() - 2)
|
||
: $preview;
|
||
}
|
||
|
||
return filled($this->primary_domain) ? (string) $this->primary_domain : null;
|
||
}
|
||
|
||
public function databases(): HasMany
|
||
{
|
||
return $this->hasMany(HostedDatabase::class);
|
||
}
|
||
|
||
public function teamMembers(): HasMany
|
||
{
|
||
return $this->hasMany(HostingAccountMember::class);
|
||
}
|
||
|
||
public function developers(): BelongsToMany
|
||
{
|
||
return $this->belongsToMany(User::class, 'hosting_account_members')
|
||
->withPivot(['role', 'invited_by_user_id', 'invited_at'])
|
||
->withTimestamps();
|
||
}
|
||
|
||
public function alerts(): HasMany
|
||
{
|
||
return $this->hasMany(HostingAccountAlert::class);
|
||
}
|
||
|
||
public function mailboxes(): HasMany
|
||
{
|
||
return $this->hasMany(Mailbox::class, 'hosting_account_id');
|
||
}
|
||
|
||
/** The extracted hosting app does not store mailboxes locally — they live on Ladill Email. */
|
||
public static function tracksLocalMailboxes(): bool
|
||
{
|
||
static $hasTable = null;
|
||
|
||
return $hasTable ??= Schema::hasTable('mailboxes');
|
||
}
|
||
|
||
/** @return Collection<int, Mailbox> */
|
||
public function loadedMailboxes(): Collection
|
||
{
|
||
if (! static::tracksLocalMailboxes()) {
|
||
return new Collection;
|
||
}
|
||
|
||
if (! $this->relationLoaded('mailboxes')) {
|
||
$this->load('mailboxes');
|
||
}
|
||
|
||
return $this->mailboxes;
|
||
}
|
||
|
||
public function billingInvoices(): HasMany
|
||
{
|
||
return $this->hasMany(HostingBillingInvoice::class, 'hosting_account_id');
|
||
}
|
||
|
||
public function planChanges(): HasMany
|
||
{
|
||
return $this->hasMany(HostingPlanChange::class, 'hosting_account_id');
|
||
}
|
||
|
||
public function latestOrder(): HasOne
|
||
{
|
||
return $this->hasOne(CustomerHostingOrder::class, 'hosting_account_id')->latestOfMany();
|
||
}
|
||
|
||
public function isActive(): bool
|
||
{
|
||
return $this->status === 'active';
|
||
}
|
||
|
||
public function scopeActive($query)
|
||
{
|
||
return $query->where('status', 'active');
|
||
}
|
||
|
||
public function isThrottled(): bool
|
||
{
|
||
return $this->resource_status === self::RESOURCE_STATUS_THROTTLED;
|
||
}
|
||
|
||
public function uploadsAreRestricted(): bool
|
||
{
|
||
return (bool) $this->uploads_restricted || (
|
||
$this->inode_limit > 0 && $this->inode_count >= $this->inode_limit
|
||
);
|
||
}
|
||
|
||
public function assignedDurationMonths(): ?int
|
||
{
|
||
$months = data_get($this->metadata, 'assigned_duration_months');
|
||
|
||
return is_numeric($months) && (int) $months > 0 ? (int) $months : null;
|
||
}
|
||
|
||
public function canBeRenewed(): bool
|
||
{
|
||
return $this->assignedDurationMonths() !== null;
|
||
}
|
||
|
||
public function requestedDiskGb(): int
|
||
{
|
||
if ($this->allocated_disk_gb > 0) {
|
||
return (int) $this->allocated_disk_gb;
|
||
}
|
||
|
||
return (int) ($this->product?->disk_gb
|
||
?? data_get($this->resource_limits, 'disk_gb')
|
||
?? 0);
|
||
}
|
||
|
||
public function diskLimitBytes(): int
|
||
{
|
||
return max($this->requestedDiskGb(), 0) * 1073741824;
|
||
}
|
||
|
||
public function diskUsagePercent(): float
|
||
{
|
||
$limit = $this->diskLimitBytes();
|
||
|
||
if ($limit <= 0) {
|
||
return 0;
|
||
}
|
||
|
||
return round(($this->disk_used_bytes / $limit) * 100, 2);
|
||
}
|
||
|
||
public function inodeUsagePercent(): float
|
||
{
|
||
if (! $this->inode_limit || $this->inode_limit <= 0) {
|
||
return 0;
|
||
}
|
||
|
||
return round(($this->inode_count / $this->inode_limit) * 100, 2);
|
||
}
|
||
|
||
public function includedEmailAccounts(): ?int
|
||
{
|
||
$limit = $this->product?->max_email_accounts;
|
||
|
||
if ($limit === null) {
|
||
$limit = data_get($this->resource_limits, 'max_email_accounts');
|
||
}
|
||
|
||
return $limit === null ? null : (int) $limit;
|
||
}
|
||
|
||
public function freeEmailAllowance(): ?int
|
||
{
|
||
$included = $this->includedEmailAccounts();
|
||
|
||
if ($included === null || $included < 0) {
|
||
return null;
|
||
}
|
||
|
||
return max($included, 0);
|
||
}
|
||
|
||
/**
|
||
* Domain slot limit from resource limits / product. -1 means unlimited.
|
||
*/
|
||
public function maxDomainsLimit(): int
|
||
{
|
||
$limit = data_get($this->resource_limits, 'max_domains');
|
||
if ($limit === null) {
|
||
$limit = $this->product?->max_domains;
|
||
}
|
||
|
||
if ($limit === null) {
|
||
return 1;
|
||
}
|
||
|
||
return (int) $limit;
|
||
}
|
||
|
||
/**
|
||
* Subdomain slot limit: max_domains × 5. Unlimited domains → unlimited subdomains.
|
||
*/
|
||
public function maxSubdomainsLimit(): int
|
||
{
|
||
$maxDomains = $this->maxDomainsLimit();
|
||
|
||
return $maxDomains === -1 ? -1 : max(0, $maxDomains) * 5;
|
||
}
|
||
|
||
public function domainSitesCount(): int
|
||
{
|
||
return $this->sites()
|
||
->whereIn('type', ['primary', 'addon'])
|
||
->count();
|
||
}
|
||
|
||
public function subdomainSitesCount(): int
|
||
{
|
||
return $this->sites()
|
||
->where('type', 'subdomain')
|
||
->count();
|
||
}
|
||
|
||
public function canAddDomain(): bool
|
||
{
|
||
$limit = $this->maxDomainsLimit();
|
||
|
||
return $limit === -1 || $this->domainSitesCount() < $limit;
|
||
}
|
||
|
||
public function canAddSubdomain(): bool
|
||
{
|
||
$limit = $this->maxSubdomainsLimit();
|
||
|
||
return $limit === -1 || $this->subdomainSitesCount() < $limit;
|
||
}
|
||
|
||
public function paidMailboxCount(): int
|
||
{
|
||
if (! static::tracksLocalMailboxes()) {
|
||
return 0;
|
||
}
|
||
|
||
return $this->loadedMailboxes()->where('is_paid', true)->count();
|
||
}
|
||
|
||
public function renew(?int $durationMonths = null, array $metadata = []): void
|
||
{
|
||
$months = $durationMonths ?? $this->assignedDurationMonths();
|
||
|
||
if (! is_int($months) || $months < 1) {
|
||
return;
|
||
}
|
||
|
||
$baseDate = $this->expires_at instanceof CarbonInterface && $this->expires_at->isFuture()
|
||
? $this->expires_at->copy()
|
||
: now();
|
||
|
||
$currentMetadata = (array) ($this->metadata ?? []);
|
||
|
||
$mergedMetadata = array_merge($currentMetadata, $metadata, [
|
||
'assigned_duration_months' => $months,
|
||
]);
|
||
unset($mergedMetadata['expiry_alerts_sent']);
|
||
|
||
$this->update([
|
||
'expires_at' => $baseDate->addMonthsNoOverflow($months),
|
||
'metadata' => $mergedMetadata,
|
||
]);
|
||
}
|
||
|
||
public function isExpired(): bool
|
||
{
|
||
return $this->expires_at instanceof CarbonInterface && $this->expires_at->isPast();
|
||
}
|
||
|
||
public function isInGracePeriod(): bool
|
||
{
|
||
if (! $this->isExpired()) {
|
||
return false;
|
||
}
|
||
|
||
return $this->gracePeriodEndsAt()->isFuture();
|
||
}
|
||
|
||
public function gracePeriodEndsAt(): ?CarbonInterface
|
||
{
|
||
if (! $this->expires_at instanceof CarbonInterface) {
|
||
return null;
|
||
}
|
||
|
||
return $this->expires_at->copy()->addMonths(self::GRACE_PERIOD_MONTHS);
|
||
}
|
||
|
||
public function isPastGracePeriod(): bool
|
||
{
|
||
$endsAt = $this->gracePeriodEndsAt();
|
||
|
||
return $endsAt !== null && $endsAt->isPast();
|
||
}
|
||
}
|