Files
ladill-hosting/app/Models/HostingAccount.php
T
isaaccladandCursor 460edb8719
Deploy Ladill Hosting / deploy (push) Successful in 1m9s
Add first-class hosting subdomain create/attach in Domains panel.
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>
2026-07-13 12:08:41 +00:00

433 lines
12 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<?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();
}
}