where('instance_id', $instance->id) ->where('period', InstanceTraffic::currentPeriod()) ->first(); return new self( instance: $instance, usedBytes: (int) ($row?->tx_bytes ?? 0), quotaBytes: self::quotaGb($instance) * 1000 ** 3, throttled: (bool) ($row?->throttled ?? false), ); } /** * Plan allowance plus purchased add-on packs. * * The allowance is the contract's, not the catalogue's: cutting a plan's * traffic must not start throttling someone who bought the larger one. An * instance with no contract has nothing to meter against and is treated as * unmetered rather than as exhausted — a machine we cannot price should not * be throttled on a guess. */ public static function quotaGb(Instance $instance): int { $plan = (int) ($instance->subscription?->traffic_gb ?? 0); $addonGb = (int) config('provisioning.traffic.addon.gb', 1000); return $plan + ($instance->traffic_addons ?? 0) * $addonGb; } public function percent(): float { if ($this->quotaBytes <= 0) { return 0.0; // an unmetered plan is never "at" anything } return round($this->usedBytes / $this->quotaBytes * 100, 1); } public function remainingBytes(): int { return max(0, $this->quotaBytes - $this->usedBytes); } public function isExhausted(): bool { return $this->quotaBytes > 0 && $this->usedBytes >= $this->quotaBytes; } /** * ok → normal, warn → 80 %, critical → 95 %, exhausted → throttled. * Thresholds are config so the portal wording and the collector's decision * cannot drift apart. */ public function state(): string { if ($this->isExhausted()) { return 'exhausted'; } [$warn, $critical] = array_pad((array) config('provisioning.traffic.warn_percent', [80, 95]), 2, 95); $percent = $this->percent(); return match (true) { $percent >= $critical => 'critical', $percent >= $warn => 'warn', default => 'ok', }; } /** Days left in the billing month — "resets in 6 days" beats a raw date. */ public function daysUntilReset(): int { return (int) ceil(now()->diffInDays(now()->endOfMonth(), absolute: true)); } }