'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 */ 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(); } }