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'] ?? '', ]) }}

diff --git a/tests/Feature/Billing/StorageAllowanceTest.php b/tests/Feature/Billing/StorageAllowanceTest.php index 71cb2c8..4739063 100644 --- a/tests/Feature/Billing/StorageAllowanceTest.php +++ b/tests/Feature/Billing/StorageAllowanceTest.php @@ -296,7 +296,11 @@ function storedBytes(Instance $instance, int $gib, ?string $day = null): Instanc it('still blocks a downgrade the stored data does not fit, and says what would fix it', function () { $fixture = storageFixture('business'); // 1000 GB package - storedBytes($fixture['instance'], 600); + // Fünfzig Gigabyte zu viel: drei Blöcke à 20 GB decken sie, und das ist + // genau der Deckel — der letzte Betrag, für den Kaufen überhaupt noch ein + // Ausweg ist. Vorher standen hier 600 GB, aus der Zeit, als ein Block noch + // 100 GB brachte; seit Task 5 wären das fünf Blöcke und damit keiner. + storedBytes($fixture['instance'], 550); $team = app(PlanCatalogue::class)->sellable()['team']; // 500 GB $check = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team'); @@ -305,10 +309,11 @@ it('still blocks a downgrade the stored data does not fit, and says what would f ->and(collect($check->blockers)->pluck('key')->all())->toBe(['storage']) ->and($check->storageRemedy)->not->toBeNull() ->and($check->storageRemedy['limit'])->toBe('500 GB') - // Fünf Blöcke à 20 GB (Task 5) decken die hundert Gigabyte, die zu viel - // sind. - ->and($check->storageRemedy['packs'])->toBe(5) - ->and($check->storageRemedy['pack_gb'])->toBe(20); + ->and($check->storageRemedy['packs'])->toBe(3) + ->and($check->storageRemedy['pack_gb'])->toBe(20) + // Gedeckt, also kein Restbetrag — und damit der Satz, der zum Knopf + // gehört, und nicht der, der ihn ersetzt. + ->and($check->storageRemedy['short'])->toBeNull(); }); it('lets exactly that downgrade through once the offered packs are booked', function () { @@ -334,9 +339,134 @@ it('lets exactly that downgrade through once the offered packs are booked', func ->and($after->blockers)->toBe([]); }); +/** + * Der Ausweg muss auch kaufmännisch gehbar sein, nicht nur rechnerisch. + * + * `packsToCover()` rechnet, und die Rechnung kennt keinen Deckel: Hundert + * Gigabyte zu viel waren fünf Blöcke, und höchstens drei sind buchbar. Die + * Karte bot „5 × Zusatzspeicher buchen (+100 GB)“ an, das Modal klemmte still + * auf drei, BookAddon hätte den vierten ohnehin abgelehnt — und der Kunde + * stand nach 45 € im Monat genau dort, wo er vorher stand. Solange ein Block + * 100 GB brachte, war das unerreichbar; seit Task 5 sind es 20, und jede + * Überschreitung über 60 GB landet hier. + */ +it('verspricht keine Blöcke, die dieser Vertrag gar nicht buchen könnte', function () { + $fixture = storageFixture('business'); + storedBytes($fixture['instance'], 600); // 100 GB zu viel für Team = fünf Blöcke + + $team = app(PlanCatalogue::class)->sellable()['team']; + $check = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $team, 'team'); + + expect($check->allowed)->toBeFalse() + // Kein Angebot statt eines unerfüllbaren: die Karte hängt ihren Knopf + // an diese Zahl. + ->and($check->storageRemedy['packs'])->toBe(0) + // Und was nach allen drei buchbaren Blöcken übrig bliebe, in Ziffern — + // nicht die vollen 107 GB, die ohne Blöcke wegmüssten. + ->and($check->storageRemedy['short'])->toBe('42,9 GB') + ->and($check->storageRemedy['over'])->toBe('107 GB'); +}); + +/** + * Der Beleg, dass das alte Versprechen wirklich keines war: Kunde folgt dem + * Rat, kauft das Höchstmaß — und der Wechsel bleibt gesperrt. + */ +it('bleibt gesperrt, auch wenn der Kunde das Höchstmaß an Blöcken bucht', function () { + $fixture = storageFixture('business'); + storedBytes($fixture['instance'], 600); + + foreach (range(1, 3) as $ignored) { + app(BookAddon::class)($fixture['subscription'], AddonCatalogue::STORAGE); + } + + // Der vierte, dem die alte Empfehlung entsprochen hätte, geht nicht. + expect(fn () => app(BookAddon::class)($fixture['subscription']->refresh(), AddonCatalogue::STORAGE)) + ->toThrow(RuntimeException::class); + + $team = app(PlanCatalogue::class)->sellable()['team']; + $after = DowngradeCheck::for($fixture['customer'], $fixture['instance']->fresh(), $team, 'team'); + + expect($after->allowed)->toBeFalse() + ->and($after->storageRemedy['packs'])->toBe(0) + ->and($after->storageRemedy['short'])->toBe('42,9 GB'); +}); + +/** Dieselbe Grenze in der Ansicht: kein Knopf, und der Satz sagt warum. */ +it('bietet auf der Karte keinen Kauf an, den der Vertrag nicht abschließen kann', function () { + $fixture = storageFixture('business'); + storedBytes($fixture['instance'], 600); + + Livewire::actingAs($fixture['user']) + ->test(Billing::class) + ->assertDontSee('confirm-book-storage') + ->assertSee(__('billing.downgrade_escape.capped', [ + 'used' => '644 GB', + 'limit' => '500 GB', + 'over' => '107 GB', + 'short' => '42,9 GB', + ])) + // Der andere Ausweg bleibt: löschen und neu messen. + ->assertSee(__('billing.downgrade_escape.remeasure')); +}); + +/** + * Der zweite Grund, aus dem BookAddon ablehnt, und derselbe Fehler wäre er + * gewesen: Enterprise wird gar kein Zusatzspeicher verkauft. Der Deckel allein + * hätte hier „3“ geantwortet und die Karte hätte Blöcke angeboten, die es für + * dieses Paket nicht gibt. + */ +it('bietet einem Enterprise-Vertrag keine Blöcke an, den es dort nicht gibt', function () { + $fixture = storageFixture('enterprise'); + storedBytes($fixture['instance'], 1200); // 200 GB zu viel für Business + + $business = app(PlanCatalogue::class)->sellable()['business']; + $check = DowngradeCheck::for($fixture['customer'], $fixture['instance'], $business, 'business'); + + expect($check->allowed)->toBeFalse() + ->and($check->storageRemedy['packs'])->toBe(0) + // Nichts buchbar, also bleibt der volle Betrag stehen — der Satz nennt + // dieselbe Zahl zweimal, und das ist die ehrliche Auskunft. + ->and($check->storageRemedy['short'])->toBe('215 GB') + ->and($check->storageRemedy['over'])->toBe('215 GB'); +}); + +/** + * Warum das Klemmen in ConfirmBookStorage (`max(1, min(…))`, das bei nichts + * mehr Buchbarem auf einen Block hochzieht) bewusst stehen bleibt: der Weg + * dahinter ist ohnehin zu. Ein Modal ist per `openModal` direkt erreichbar, + * also wird das nicht angenommen, sondern belegt — beide Gründe, aus denen + * BookAddon ablehnt, halten schon in `purchase()`, bevor eine Bestellzeile + * entsteht. + */ +it('legt über den Bestätigungsweg keine Bestellzeile an, wenn nichts buchbar ist', function () { + $amDeckel = storageFixture('business'); + app(BookAddon::class)($amDeckel['subscription'], AddonCatalogue::STORAGE, 3); + + // Eigene Adresse: `storageFixture()` vergibt sonst zweimal dieselbe, und + // `instances.subdomain` ist eindeutig. + $enterprise = storageFixture('enterprise', ['subdomain' => 'huber']); + + withStripeSecret(); + + foreach ([$amDeckel, $enterprise] as $fixture) { + expect(app(AddonCatalogue::class)->bookableQuantity($fixture['subscription']->refresh(), AddonCatalogue::STORAGE))->toBe(0); + + Livewire::actingAs($fixture['user']) + ->test(Billing::class) + // Genau das, was ein direkt geöffnetes Modal auslöst. + ->call('bookStoragePacks', 1); + + expect(Order::query()->where('customer_id', $fixture['customer']->id)->where('type', 'storage')->count())->toBe(0); + } +}); + it('offers the packs through a modal, and books exactly the number that was offered', function () { $fixture = storageFixture('business'); - storedBytes($fixture['instance'], 800); // three packs short of Team + // Sechzig Gigabyte zu viel, also genau drei Blöcke. Vorher 800 GB, was + // damals „drei Blöcke zu wenig“ hieß und seit Task 5 fünfzehn wären — die + // Karte böte dafür gar keinen Knopf mehr an, und der Test hätte das Modal + // gesucht, das sie zu Recht nicht mehr öffnet. + storedBytes($fixture['instance'], 560); Livewire::actingAs($fixture['user']) ->test(Billing::class)