96 lines
3.9 KiB
PHP
96 lines
3.9 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Billing;
|
|
|
|
use App\Models\Customer;
|
|
use App\Models\Instance;
|
|
use App\Models\InstanceMetric;
|
|
use App\Models\Seat;
|
|
use App\Support\Bytes;
|
|
|
|
/**
|
|
* Whether a customer may move down to a smaller plan, and if not, why.
|
|
*
|
|
* A downgrade is not the mirror of an upgrade. Going up always fits; going
|
|
* down can ask an instance to hold more than the target plan allows, and the
|
|
* failure has to be explained in the customer's own numbers — "you have 31
|
|
* users, Start allows 10" — rather than as a disabled button. A greyed-out
|
|
* control that does not say what is in the way is the thing people ring about.
|
|
*
|
|
* Checked against what is MEASURED, not what was sold: a customer sitting on
|
|
* 480 GB of a 500 GB plan cannot move to a 200 GB one, whatever their contract
|
|
* says the allowance is.
|
|
*
|
|
* Blockers are not the only thing worth saying beforehand. A downgrade can be
|
|
* perfectly possible and still take something away — the customer's own domain
|
|
* — and being told that afterwards is the same failure as a greyed-out button
|
|
* with no reason on it. So a consequence is reported alongside, in the same
|
|
* shape and in the same place, and it does not make the move impossible: it is
|
|
* the sentence somebody reads before they agree to it.
|
|
*/
|
|
final readonly class DowngradeCheck
|
|
{
|
|
private function __construct(
|
|
public bool $allowed,
|
|
/** @var array<int, array{key: string, current: string, limit: string}> */
|
|
public array $blockers,
|
|
/** @var array<int, array{key: string, replace: array<string, string>}> */
|
|
public array $consequences = [],
|
|
) {}
|
|
|
|
/**
|
|
* @param array<string, mixed> $target the catalogue entry being moved to
|
|
* @param ?string $targetPlan its plan key — null when a caller is checking raw
|
|
* limits rather than a package, and then the
|
|
* consequences that depend on which package it is
|
|
* cannot be computed and are not guessed at
|
|
*/
|
|
public static function for(Customer $customer, ?Instance $instance, array $target, ?string $targetPlan = null): self
|
|
{
|
|
$blockers = [];
|
|
|
|
$seats = Seat::query()
|
|
->where('customer_id', $customer->id)
|
|
->whereIn('status', ['active', 'invited'])
|
|
->count();
|
|
|
|
$seatLimit = (int) ($target['seats'] ?? 0);
|
|
|
|
if ($seatLimit > 0 && $seats > $seatLimit) {
|
|
$blockers[] = [
|
|
'key' => 'seats',
|
|
'current' => (string) $seats,
|
|
'limit' => (string) $seatLimit,
|
|
];
|
|
}
|
|
|
|
// Storage is checked against the last real reading. Falling back to the
|
|
// contractual allowance would refuse a downgrade to anyone who has ever
|
|
// bought a large plan, however empty their instance is.
|
|
$quotaGb = (int) ($target['quota_gb'] ?? 0);
|
|
$metric = $instance !== null ? InstanceMetric::latestDisk($instance) : null;
|
|
|
|
if ($quotaGb > 0 && $metric !== null && $metric->disk_used_bytes > $quotaGb * 1024 ** 3) {
|
|
$blockers[] = [
|
|
'key' => 'storage',
|
|
'current' => Bytes::human($metric->disk_used_bytes),
|
|
'limit' => $quotaGb.' GB',
|
|
];
|
|
}
|
|
|
|
// Asked, never re-derived: which packages may carry an own domain is
|
|
// CustomDomainAccess's question, and this class only reports what it
|
|
// answers about the move being considered.
|
|
$access = app(CustomDomainAccess::class);
|
|
|
|
$consequences = $targetPlan === null ? [] : $access->consequencesOfMovingTo(
|
|
current: $access->contractOf($customer),
|
|
instance: $instance,
|
|
targetPlan: $targetPlan,
|
|
targetFeatures: array_values((array) ($target['features'] ?? [])),
|
|
);
|
|
|
|
return new self($blockers === [], $blockers, $consequences);
|
|
}
|
|
}
|