169 lines
6.6 KiB
PHP
169 lines
6.6 KiB
PHP
<?php
|
|
|
|
namespace App\Services\Billing;
|
|
|
|
use App\Models\Instance;
|
|
use App\Models\Subscription;
|
|
|
|
/**
|
|
* How much a customer may actually store: the package's allowance plus every
|
|
* storage pack they have booked.
|
|
*
|
|
* The packs were sold and never delivered. `AddonCatalogue::STORAGE` reached
|
|
* `subscription_addons` — priced, frozen, charged for every month — and stopped
|
|
* there: Nextcloud's quota was written from `instances.quota_gb`, which is the
|
|
* package alone, so a customer could pay ten euros a month for a hundred
|
|
* gigabytes that no part of the product ever gave them. This is the one place
|
|
* that adds the two together, and everything that needs to know the figure —
|
|
* the quota step, the downgrade check, the portal, the console — asks HERE.
|
|
* Nothing re-derives it from `quota_gb`.
|
|
*
|
|
* **Derived, not stored, and deliberately so.** A column would have to be
|
|
* rewritten by every path that can move either half of the sum: booking a pack,
|
|
* cancelling one, an operator's grant, a grant expiring, a plan change, the
|
|
* contract being replaced. The day one of those paths forgets, the column is
|
|
* silently wrong in whichever direction is worse — a customer charged for
|
|
* storage they cannot use, or one still holding storage they have stopped paying
|
|
* for — and it would be wrong invisibly, because a column looks authoritative.
|
|
* A sum of two rows that are already the authority cannot drift from them.
|
|
*
|
|
* `instances.quota_applied_gb` is NOT the same thing and is not duplicated here.
|
|
* That column records what the guest was last actually TOLD, which is the only
|
|
* way to tell a machine whose allowance is enforced from one where the figure
|
|
* has only ever been a row in our database. This class answers what the customer
|
|
* is OWED; the two disagreeing is exactly the signal that column exists for.
|
|
*/
|
|
final readonly class StorageAllowance
|
|
{
|
|
public function __construct(
|
|
/** What the package itself includes. */
|
|
public int $planGb,
|
|
/** How many packs are running — the figure the customer reads. */
|
|
public int $packs,
|
|
/** What these packs give the customer, at the sizes they were booked. */
|
|
public int $bookedGb,
|
|
/** What they take up on the host, at the sizes they were booked. */
|
|
public int $bookedDiskGb,
|
|
/** How big a pack would be TODAY — for quotes, not for what is already booked. */
|
|
public int $packSizeGb,
|
|
) {}
|
|
|
|
/**
|
|
* What the customer behind this machine may store.
|
|
*
|
|
* The machine's own row first and the contract only as a fallback: the
|
|
* instance is what provisioning wrote and what the host's storage accounting
|
|
* was sized from, while a contract exists before there is a machine at all.
|
|
*/
|
|
public static function for(?Instance $instance): self
|
|
{
|
|
if ($instance === null) {
|
|
return self::forPlan(0, null);
|
|
}
|
|
|
|
// The link when provisioning has written it, and otherwise the
|
|
// customer's live contract — the same resolution the portal and the
|
|
// console's grant screen make, asked of the one class that answers it.
|
|
$contract = $instance->subscription
|
|
?? app(CustomDomainAccess::class)->contractOf($instance->customer);
|
|
|
|
return self::forPlan((int) ($instance->quota_gb ?? $contract?->quota_gb ?? 0), $contract);
|
|
}
|
|
|
|
/**
|
|
* The same question about a package the customer is not on yet.
|
|
*
|
|
* The downgrade check needs it: whether a smaller package is big enough
|
|
* depends on the packs the customer has ALREADY paid for, which move with
|
|
* them.
|
|
*/
|
|
public static function forPlan(int $planGb, ?Subscription $subscription): self
|
|
{
|
|
$booked = self::bookedPacks($subscription);
|
|
|
|
return new self(
|
|
max(0, $planGb),
|
|
$booked['packs'],
|
|
$booked['gb'],
|
|
$booked['disk_gb'],
|
|
app(AddonCatalogue::class)->packGb(),
|
|
);
|
|
}
|
|
|
|
/** The figure Nextcloud is told, the disk is sized for, and the portal shows. */
|
|
public function totalGb(): int
|
|
{
|
|
return $this->planGb + $this->packGb();
|
|
}
|
|
|
|
public function hasPacks(): bool
|
|
{
|
|
return $this->packs > 0;
|
|
}
|
|
|
|
/**
|
|
* How many MORE packs it would take to cover this many bytes.
|
|
*
|
|
* Zero when the allowance already covers it. Rounded up, because half a pack
|
|
* is not for sale and an answer that leaves the customer one gigabyte short
|
|
* is an answer that sends them back a second time.
|
|
*
|
|
* ARITHMETIC ONLY, deliberately: this says how many packs the bytes need,
|
|
* never how many the customer may buy. The ceiling and the packages a pack
|
|
* is sold on are commercial rules, they live in AddonCatalogue, and the
|
|
* caller that holds the contract applies them — see DowngradeCheck, which is
|
|
* where the answer is turned into an offer. Clamping here instead would put
|
|
* a contract lookup into every construction of this class, including the
|
|
* quota step and the portal, neither of which is asking a sales question.
|
|
*/
|
|
public function packsToCover(int $bytes): int
|
|
{
|
|
$short = $bytes - $this->totalGb() * 1024 ** 3;
|
|
|
|
if ($short <= 0 || $this->packSizeGb <= 0) {
|
|
return 0;
|
|
}
|
|
|
|
return (int) ceil($short / ($this->packSizeGb * 1024 ** 3));
|
|
}
|
|
|
|
/**
|
|
* The packs still running on this contract, with their own sizes.
|
|
*
|
|
* Queried rather than read off a loaded relation, for the same reason as
|
|
* before: this is asked immediately after a pack is booked or cancelled,
|
|
* and a relation loaded before that would answer with the state before
|
|
* the change.
|
|
*
|
|
* @return array{packs: int, gb: int, disk_gb: int}
|
|
*/
|
|
private static function bookedPacks(?Subscription $subscription): array
|
|
{
|
|
if ($subscription === null) {
|
|
return ['packs' => 0, 'gb' => 0, 'disk_gb' => 0];
|
|
}
|
|
|
|
$rows = $subscription->addons()->active()
|
|
->where('addon_key', AddonCatalogue::STORAGE)
|
|
->get(['quantity', 'pack_gb', 'pack_disk_gb']);
|
|
|
|
return [
|
|
'packs' => (int) $rows->sum('quantity'),
|
|
'gb' => (int) $rows->sum(fn ($row) => (int) $row->quantity * (int) $row->pack_gb),
|
|
'disk_gb' => (int) $rows->sum(fn ($row) => (int) $row->quantity * (int) $row->pack_disk_gb),
|
|
];
|
|
}
|
|
|
|
/** Everything the booked packs give the customer. */
|
|
public function packGb(): int
|
|
{
|
|
return $this->bookedGb;
|
|
}
|
|
|
|
/** Everything the booked packs take up on the host. */
|
|
public function packDiskGb(): int
|
|
{
|
|
return $this->bookedDiskGb;
|
|
}
|
|
}
|