CluPilotCloud/app/Services/Billing/DowngradeCheck.php

128 lines
5.7 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.
*
* A blocker on storage carries the way OUT of it as well, because "no" without a
* way out is where this check used to stop. There are exactly two ways out and
* both are real: buy enough storage packs that the smaller package plus the
* packs covers what is stored — the packs count towards the limit here, on the
* same authority everything else reads — or delete data and ask again against a
* fresh reading rather than last night's.
*/
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 = [],
/**
* The numbers behind a storage blocker, or null when storage is not what
* is in the way. Real figures, never a shrug: what is stored, what the
* target package plus the packs already booked would allow, how much has
* to go, and how many further packs would cover it instead.
*
* @var array{used: string, limit: string, over: string, packs: int, pack_gb: int}|null
*/
public ?array $storageRemedy = null,
) {}
/**
* @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,
];
}
// 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);
$contract = $access->contractOf($customer);
// 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.
//
// What it is checked AGAINST is the target package plus the storage packs
// the customer has already booked, because those move with them: a
// customer on 200 GB with three packs may hold 500 GB, and refusing them
// the smaller package would be refusing them storage they are paying for.
$planGb = (int) ($target['quota_gb'] ?? 0);
$allowance = StorageAllowance::forPlan($planGb, $contract);
$metric = $instance !== null ? InstanceMetric::latestDisk($instance) : null;
$storageRemedy = null;
if ($planGb > 0 && $metric !== null && $metric->disk_used_bytes > $allowance->totalGb() * 1024 ** 3) {
$blockers[] = [
'key' => 'storage',
'current' => Bytes::human($metric->disk_used_bytes),
'limit' => $allowance->totalGb().' GB',
];
$storageRemedy = [
'used' => Bytes::human($metric->disk_used_bytes),
'limit' => $allowance->totalGb().' GB',
'over' => Bytes::human($metric->disk_used_bytes - $allowance->totalGb() * 1024 ** 3),
'packs' => $allowance->packsToCover((int) $metric->disk_used_bytes),
'pack_gb' => $allowance->packSizeGb,
];
}
$consequences = $targetPlan === null ? [] : $access->consequencesOfMovingTo(
current: $contract,
instance: $instance,
targetPlan: $targetPlan,
targetFeatures: array_values((array) ($target['features'] ?? [])),
);
return new self($blockers === [], $blockers, $consequences, $storageRemedy);
}
}