Files
isaaccladandCursor b6c8ac343f Extract Ladill Servers as standalone app at servers.ladill.com.
VPS and dedicated server ordering, managed panels, SSO, billing, and launcher integration — forked from hosting infrastructure with server-focused routes and dashboard.

Co-authored-by: Cursor <cursoragent@cursor.com>
2026-06-06 19:18:30 +00:00

378 lines
10 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);
}
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();
}
}