CluPilotCloud/app/Services/Traffic/TrafficMeter.php

103 lines
3.2 KiB
PHP

<?php
namespace App\Services\Traffic;
use App\Models\Instance;
use App\Models\InstanceTraffic;
/**
* What a customer has used this month, and what that means.
*
* The quota comes from the plan plus whatever traffic add-ons were bought; the
* usage is the outbound total the collector accumulates. Everything the portal,
* the console and the throttling decision need is derived here, so those three
* can never disagree about whether someone is over their limit.
*/
final readonly class TrafficMeter
{
private function __construct(
public Instance $instance,
public int $usedBytes,
public int $quotaBytes,
public bool $throttled,
) {}
public static function for(Instance $instance, ?InstanceTraffic $row = null): self
{
$row ??= InstanceTraffic::query()
->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));
}
}