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); +});