*/ public array $blockers, /** @var array}> */ 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. * * `packs` is what this contract may buy AND what would actually lift the * blocker — zero when no bookable number of packs does both, and then * `short` carries what would still be over after every pack the contract * could book. The two are exclusive on purpose: `short` being set is the * signal that buying is not one of the ways out here, and the card reads * it to say so instead of offering a button. * * @var array{used: string, limit: string, over: string, packs: int, pack_gb: int, short: ?string}|null */ public ?array $storageRemedy = null, ) {} /** * @param array $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', ]; // What closes the gap on paper, and what this contract may actually // buy. The second figure is the commercial one and is not re-derived // here: AddonCatalogue holds the ceiling and the packages a pack is // sold on, and it is the same question BookAddon refuses by — two // formulations of one limit would be two limits. $wanted = $allowance->packsToCover((int) $metric->disk_used_bytes); $bookable = app(AddonCatalogue::class)->bookableQuantity($contract, AddonCatalogue::STORAGE); $enough = $wanted <= $bookable; $storageRemedy = [ 'used' => Bytes::human($metric->disk_used_bytes), 'limit' => $allowance->totalGb().' GB', 'over' => Bytes::human($metric->disk_used_bytes - $allowance->totalGb() * 1024 ** 3), 'packs' => $enough ? $wanted : 0, 'pack_gb' => $allowance->packSizeGb, // Measured against every pack the contract could still book, not // against the ceiling: a customer already holding one pack has a // smaller remainder than one holding none, and quoting the // ceiling's figure at them would overstate what is left to delete. 'short' => $enough ? null : Bytes::human( (int) $metric->disk_used_bytes - ($allowance->totalGb() + $bookable * $allowance->packSizeGb) * 1024 ** 3 ), ]; } $consequences = $targetPlan === null ? [] : $access->consequencesOfMovingTo( current: $contract, instance: $instance, targetPlan: $targetPlan, targetFeatures: array_values((array) ($target['features'] ?? [])), ); return new self($blockers === [], $blockers, $consequences, $storageRemedy); } }