Ein Block wird nur gebucht, wenn der Host ihn tragen kann

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 <noreply@anthropic.com>
feat/neue-pakete
nexxo 2026-08-01 14:03:07 +02:00
parent c6ead1afc9
commit 01bcc29d13
5 changed files with 99 additions and 1 deletions

View File

@ -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) {

View File

@ -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.',

View File

@ -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.',

View File

@ -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;

View File

@ -0,0 +1,70 @@
<?php
use App\Actions\BookAddon;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
/**
* Ein Block ist Platz auf einer bestimmten Maschine.
*
* Eine laufende Instanz zieht nicht um es gibt keinen Umzug zwischen Hosts.
* Die Frage ist deshalb nicht "hat irgendein Host Platz", sondern "hat DIESER
* Host Platz", und sie muss gestellt werden, bevor Geld fließt.
*/
function packedInstanceOn(Host $host): Subscription
{
$order = Order::factory()->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);
});