From 01bcc29d13d4ec820e3bbc99787bb679ebb3f802 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 14:03:07 +0200 Subject: [PATCH] Ein Block wird nur gebucht, wenn der Host ihn tragen kann MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit HostCapacity wurde im Checkout, in der Bestellung und im Konsolenbereich gefragt — beim Aufstocken nicht. Weil qcow2 duenn belegt ist, gelingt die Ueberbuchung sofort und faellt erst auf, wenn die Gaeste wirklich schreiben. Gefragt wird der Host der Instanz, denn eine laufende Instanz zieht nicht um. StorageAllowanceTest: der Host in storageFixture() war mit 1000 GB (active() Standard) schon fuer eine reine Business-Instanz (1050 GB disk_gb) zu klein — placeableIn() haette sie nie dort platziert. Ohne Kapazitaetspruefung fiel das nie auf; jetzt schon, deshalb auf 2000 GB angehoben. Co-Authored-By: Claude Opus 5 --- app/Actions/BookAddon.php | 17 +++++ lang/de/billing.php | 3 + lang/en/billing.php | 3 + .../Feature/Billing/StorageAllowanceTest.php | 7 +- .../Billing/StoragePackCapacityTest.php | 70 +++++++++++++++++++ 5 files changed, 99 insertions(+), 1 deletion(-) create mode 100644 tests/Feature/Billing/StoragePackCapacityTest.php diff --git a/app/Actions/BookAddon.php b/app/Actions/BookAddon.php index c25df5c..41b7878 100644 --- a/app/Actions/BookAddon.php +++ b/app/Actions/BookAddon.php @@ -90,6 +90,23 @@ class BookAddon throw new RuntimeException($overLimit); } + // Platz auf DIESER Maschine, nicht irgendwo im Bestand: eine laufende + // Instanz zieht nicht um. Dünn belegter Speicher lässt eine + // Überbuchung sofort gelingen und erst auffallen, wenn die Gäste + // wirklich schreiben — also unter zahlenden Kunden. + // + // Ohne Maschine wird nicht gefragt: der Vertrag ist gerade erst + // geschlossen, und die Platzierung nimmt die Blöcke in + // ReserveResources mit auf. + if ($addonKey === AddonCatalogue::STORAGE) { + $host = $subscription->instance?->host; + $needs = app(AddonCatalogue::class)->packDiskGb() * $quantity; + + if ($host !== null && ! $host->canTake($needs)) { + throw new RuntimeException(__('billing.storage_no_room')); + } + } + try { $addon = $this->book($subscription, $addonKey, $quantity, $order, $price, $overrides); } catch (UniqueConstraintViolationException) { diff --git a/lang/de/billing.php b/lang/de/billing.php index 5dbd5e0..b4e42ee 100644 --- a/lang/de/billing.php +++ b/lang/de/billing.php @@ -146,6 +146,9 @@ return [ 'addon_in_cart' => '„:module“ liegt bereits im Warenkorb und wird nach der Zahlung freigeschaltet.', 'addon_in_cart_badge' => 'Im Warenkorb', 'addon_limit_reached' => ':module ist auf :max Einheiten begrenzt. Ein größeres Paket bietet mehr Speicher für weniger Geld.', + // Ein Block ist Platz auf einer bestimmten Maschine — eine laufende + // Instanz zieht nicht um. Gemeint ist der Host, nicht der Vertrag. + 'storage_no_room' => 'Auf Ihrem Server ist derzeit kein Platz für weiteren Speicher. Bitte wenden Sie sich an den Support — wir sorgen für Raum.', // Der gebuchte Wechsel, auf der Karte, die sagt, was der Kunde hat. 'pending_change_note' => 'Wechsel auf :plan zum :date vorgemerkt. Bis dahin ändert sich nichts an Ihrem Paket.', 'purchased' => 'Kauf vorgemerkt — wir schalten ihn nach der Zahlung frei.', diff --git a/lang/en/billing.php b/lang/en/billing.php index c3f3c28..da4d068 100644 --- a/lang/en/billing.php +++ b/lang/en/billing.php @@ -146,6 +146,9 @@ return [ 'addon_in_cart' => 'The :module module is already in your cart and will be activated after payment.', 'addon_in_cart_badge' => 'In cart', 'addon_limit_reached' => ':module is limited to :max units. A larger package gives you more storage for less money.', + // A pack is room on a particular machine — a running instance does not + // move. This is about the host, not the contract. + 'storage_no_room' => 'Your server currently has no room for more storage. Please contact support — we will make room.', // The booked change, on the card that says what the customer has. 'pending_change_note' => 'A move to :plan is booked for :date. Nothing about your package changes until then.', 'purchased' => 'Purchase noted — we activate it after payment.', diff --git a/tests/Feature/Billing/StorageAllowanceTest.php b/tests/Feature/Billing/StorageAllowanceTest.php index 821b681..f833444 100644 --- a/tests/Feature/Billing/StorageAllowanceTest.php +++ b/tests/Feature/Billing/StorageAllowanceTest.php @@ -40,7 +40,12 @@ use Livewire\Livewire; /** A customer on a package, with a machine that is built, running and served. */ function storageFixture(string $plan = 'team', array $instanceAttributes = []): array { - $host = Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve']); + // Sized above the biggest plan this file books (business, 1050 GB disk) + // plus room for the packs these tests add on top: since Task 3, BookAddon + // asks the host before it books, and `active()`'s default 1000 GB is + // already too small to have legitimately hosted a business instance in + // the first place — placeableIn() would never have put one there. + $host = Host::factory()->active()->create(['datacenter' => 'fsn', 'node' => 'pve', 'total_gb' => 2000]); $order = Order::factory()->withSubscription()->create(['datacenter' => 'fsn', 'plan' => $plan]); $subscription = $order->subscription; $customer = $order->customer; diff --git a/tests/Feature/Billing/StoragePackCapacityTest.php b/tests/Feature/Billing/StoragePackCapacityTest.php new file mode 100644 index 0000000..656f236 --- /dev/null +++ b/tests/Feature/Billing/StoragePackCapacityTest.php @@ -0,0 +1,70 @@ +create(['plan' => 'start', 'datacenter' => $host->datacenter, 'status' => 'paid']); + $subscription = app(App\Actions\OpenSubscription::class)($order); + + $instance = Instance::factory()->create([ + 'customer_id' => $subscription->customer_id, + 'host_id' => $host->id, + 'status' => 'active', + 'quota_gb' => 30, + 'disk_gb' => 40, + ]); + + $subscription->update(['instance_id' => $instance->id]); + + return $subscription->refresh(); +} + +it('lehnt einen Block ab, für den der Host keinen Platz hat', function () { + config(['provisioning.storage_addon.gb' => 20, 'provisioning.storage_addon.disk_gb' => 22]); + + // 60 GB gesamt, davon 40 durch die Instanz gebunden: für 22 weitere ist + // kein Platz. `active()` setzt total_gb/RAM/Kerne — ohne diesen Zustand + // hat ein Host gar keine Größe. + $host = Host::factory()->active()->create(['datacenter' => 'fsn', 'total_gb' => 60]); + $subscription = packedInstanceOn($host); + + expect(fn () => app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1)) + ->toThrow(RuntimeException::class); + + expect($subscription->addons()->count())->toBe(0); +}); + +it('lässt einen Block durch, für den der Host Platz hat', function () { + config(['provisioning.storage_addon.gb' => 20, 'provisioning.storage_addon.disk_gb' => 22]); + + $host = Host::factory()->active()->create(['datacenter' => 'fsn', 'total_gb' => 500]); + $subscription = packedInstanceOn($host); + + app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1); + + expect((int) $subscription->addons()->active()->sum('quantity'))->toBe(1); +}); + +it('fragt nicht, solange es keine Maschine gibt', function () { + config(['provisioning.storage_addon.gb' => 20, 'provisioning.storage_addon.disk_gb' => 22]); + + $order = Order::factory()->create(['plan' => 'start', 'datacenter' => 'fsn', 'status' => 'paid']); + $subscription = app(App\Actions\OpenSubscription::class)($order); + + app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1); + + expect((int) $subscription->addons()->active()->sum('quantity'))->toBe(1); +});