diff --git a/app/Services/Billing/AddonCatalogue.php b/app/Services/Billing/AddonCatalogue.php index b5fa1f7..9936431 100644 --- a/app/Services/Billing/AddonCatalogue.php +++ b/app/Services/Billing/AddonCatalogue.php @@ -150,9 +150,22 @@ final class AddonCatalogue return $max === null ? null : max(1, (int) $max); } - /** Wie viele davon dieser Vertrag jetzt noch buchen könnte. */ + /** + * Wie viele davon dieser Vertrag jetzt noch buchen könnte. + * + * BEIDE Gründe, aus denen BookAddon eine Buchung abweist, nicht nur der + * Deckel: ein Modul, das es für dieses Paket gar nicht gibt, ist null Mal + * buchbar und nicht drei Mal. Vorher antwortete diese Methode einem + * Enterprise-Vertrag „3" auf die Frage, die ihr eigener Docblock stellt — + * eine Zahl, die die Aktion dahinter jedes Mal abgelehnt hätte, und ein + * Aufrufer, der sie einem Kunden zeigt, verspricht etwas Unmögliches. + */ public function bookableQuantity(?Subscription $subscription, string $key): int { + if ($this->availabilityRefusal($subscription, $key) !== null) { + return 0; + } + $max = $this->maxQuantity($key); if ($max === null) { diff --git a/app/Services/Billing/DowngradeCheck.php b/app/Services/Billing/DowngradeCheck.php index fa1b979..c8b9248 100644 --- a/app/Services/Billing/DowngradeCheck.php +++ b/app/Services/Billing/DowngradeCheck.php @@ -34,6 +34,17 @@ use App\Support\Bytes; * 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. + * + * "Both are real" is a promise, and it has to be kept against the COMMERCIAL + * ceiling and not only against the arithmetic. Packs are capped per contract and + * are not sold on every package at all, so the number that closes the gap on + * paper is not always a number anyone may book — and once a pack shrank from 100 + * GB usable to 20, every overage above three packs' worth landed there. The + * remedy therefore offers packs only when the packs a contract may ACTUALLY book + * would clear the blocker, and otherwise offers none and says how much would + * still be left over. An offer that BookAddon would refuse, or that costs money + * every month and leaves the downgrade blocked anyway, is not a way out — it is + * the greyed-out button again, wearing a price tag. */ final readonly class DowngradeCheck { @@ -49,7 +60,14 @@ final readonly class DowngradeCheck * 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 + * `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, ) {} @@ -106,12 +124,29 @@ final readonly class DowngradeCheck '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' => $allowance->packsToCover((int) $metric->disk_used_bytes), + '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 + ), ]; } diff --git a/app/Services/Billing/StorageAllowance.php b/app/Services/Billing/StorageAllowance.php index dc7d350..d11aeee 100644 --- a/app/Services/Billing/StorageAllowance.php +++ b/app/Services/Billing/StorageAllowance.php @@ -107,6 +107,14 @@ final readonly class StorageAllowance * 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 { diff --git a/lang/de/billing.php b/lang/de/billing.php index b7750cb..d0a3e43 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -33,6 +33,12 @@ return [ // endete. Es gibt genau zwei Auswege, und beide stehen hier mit Zahlen. 'downgrade_escape' => [ 'intro' => 'Sie belegen :used, mit diesem Paket wären :limit möglich — :over müssen weg. Sie können stattdessen Zusatzspeicher buchen; gebuchte Pakete zählen zu Ihrem Kontingent.', + // Wenn Kaufen keiner der beiden Auswege mehr ist. Der Satz nennt die + // Lücke, die bliebe, und nicht den Grund: „höchstens drei Blöcke“ wäre + // im Enterprise-Fall falsch, wo es gar keine gibt, und eine Begründung, + // die in der Hälfte der Fälle daneben liegt, ist schlechter als eine + // allgemeine, die immer stimmt. + 'capped' => 'Sie belegen :used, mit diesem Paket wären :limit möglich — :over müssen weg. Zusatzspeicher schließt diese Lücke nicht: selbst mit allen Blöcken, die dieser Vertrag noch buchen kann, blieben :short zu viel. Löschen Sie Daten und messen Sie danach neu.', 'book' => ':count × Zusatzspeicher buchen (+:gb GB)', 'remeasure' => 'Speicher neu messen', 'measured_at' => 'Gemessen am :when. Nach dem Löschen von Daten hier neu messen, statt auf die nächtliche Messung zu warten.', diff --git a/lang/en/billing.php b/lang/en/billing.php index ee56682..7144994 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -33,6 +33,11 @@ return [ // There are exactly two ways out, and both are stated here with numbers. 'downgrade_escape' => [ 'intro' => 'You are using :used, this package would allow :limit — :over has to go. You can book extra storage instead; booked packs count towards your allowance.', + // When buying is no longer one of the two ways out. Names the gap that + // would remain rather than the reason: "at most three packs" would be + // wrong for Enterprise, which is sold none at all, and a reason that is + // false half the time is worse than a general one that always holds. + 'capped' => 'You are using :used, this package would allow :limit — :over has to go. Extra storage cannot close that gap: even with every pack this contract could still book, :short would remain. Delete data and measure again afterwards.', 'book' => 'Add :count × extra storage (+:gb GB)', 'remeasure' => 'Measure storage again', 'measured_at' => 'Measured on :when. After deleting data, measure again here rather than waiting for the nightly reading.', diff --git a/resources/views/livewire/billing.blade.php b/resources/views/livewire/billing.blade.php index 4135e03..2384605 100644 --- a/resources/views/livewire/billing.blade.php +++ b/resources/views/livewire/billing.blade.php @@ -324,14 +324,22 @@ room, or delete the files and have us look again — and being told to wait for tonight's reading after clearing 40 GB is the same - dead end as a greyed-out button. --}} + dead end as a greyed-out button. + + Buying is only one of them while it actually + works. Where the packs this contract may book + cannot lift the blocker, `short` is set, and + then the sentence says so and no button + offers a charge that would leave the customer + exactly where they are. --}} @if ($remedy = $plan['check']->storageRemedy)
- {{ __('billing.downgrade_escape.intro', [ + {{ __('billing.downgrade_escape.'.($remedy['short'] === null ? 'intro' : 'capped'), [ 'used' => $remedy['used'], 'limit' => $remedy['limit'], 'over' => $remedy['over'], + 'short' => $remedy['short'] ?? '', ]) }}