# Die neuen Pakete — Umsetzungsplan > **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. **Ziel:** Die Paketleiter auf die Maschine zuschneiden, die dasteht — neun Kunden je Server statt drei —, den Zusatzspeicher so bepreisen, dass Aufsteigen billiger bleibt als Stapeln, das Testpaket aus dem Verkauf nehmen und Enterprise auf Anfrage stellen. **Architektur:** Erst die drei Reparaturen am Zusatzspeicher, dann die Umschaltung des Katalogs. Diese Reihenfolge ist keine Stilfrage: die Packungsgröße wird heute zur Anzeigezeit aus der Konfiguration gelesen, also schrumpft ein gekaufter 100-GB-Block in der Sekunde auf 20 GB, in der die Konfiguration umgestellt wird. Die Umschaltung selbst ist eine Migration mit fest eingetragenen Werten, im Stil der Migration, die den Katalog ursprünglich gesetzt hat — ein Befehl allein ließe jede Neuinstallation und die gesamte Testsuite auf einer Leiter zurück, die wir nicht mehr verkaufen. **Tech-Stack:** Laravel 13.8, Livewire 3 (klassenbasiert, kein Volt), Pest, MariaDB 11.4, Tailwind v4, Stripe. **Entwurf:** `docs/superpowers/specs/2026-08-01-neue-pakete-design.md` ## Global Constraints - **Tests laufen so:** `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test` (einzelne Datei: `… php artisan test tests/Feature/…Test.php`). - **Die Reihenfolge ist bindend:** Aufgaben 1–5 vor Aufgabe 9. Wer die Konfiguration vor dem Einfrieren umstellt, verkleinert gekaufte Blöcke. - **Geld ist netto und in Cent**, umgerechnet ausschließlich über `App\Support\Money`. - **Regeln aus `rules.md` gelten unverändert**, insbesondere R23 (Bestätigungen im eigenen Modal, nie im Browserdialog) und R2 (Livewire 3, klassenbasiert). - **Jede Änderung an einer Regel gehört an die eine Stelle, die sie hält** — neue Prüfungen in die Aktion, nicht in die Ansicht. Eine im Formular versteckte Schaltfläche ist keine Prüfung. - **Commit-Nachrichten auf Deutsch**, mit `Co-Authored-By: Claude Opus 5 `. - **Nicht pushen.** Der Betreiber pusht von Hand (Token liegt in `.env`). - **Die neuen Zahlen, an einer Stelle nachschlagbar:** | Paket | Schlüssel | Kontingent | Platte | RAM | Kerne | Plätze | Traffic | Klasse | netto/Monat | Freimonate | |---|---|---|---|---|---|---|---|---|---|---| | Intern | `test` | 5 GB | 20 GB | 4096 MB | 2 | 3 | 1000 GB | standard | 100 ct | 2 | | Start | `start` | 30 GB | 40 GB | 6144 MB | 2 | 10 | 1000 GB | standard | 3900 ct | 2 | | Team | `team` | 85 GB | 100 GB | 8192 MB | 4 | 25 | 2000 GB | enhanced | 7900 ct | 2 | | Business | `business` | 175 GB | 200 GB | 12288 MB | 6 | 50 | 4000 GB | high | 13900 ct | 2 | | Enterprise | `enterprise` | unverändert | unverändert | — | — | — | — | dedicated | nicht im Verkauf | — | - **Zusatzspeicher:** 20 GB nutzbar, 22 GB Platte, 1500 ct netto, höchstens 3, nicht bei `enterprise`. --- ### Task 1: Die Packungsgröße gehört auf die Buchung Heute liest `StorageAllowance::packSizeGb()` die Größe live aus der Konfiguration. Der Kopfkommentar dort sagt selbst, was das bedeutet und wann es zu ändern ist — dieser Tag ist heute. **Files:** - Create: `database/migrations/2026_08_01_000001_add_pack_size_to_subscription_addons.php` - Modify: `app/Models/SubscriptionAddon.php` (Konstante `FROZEN`, `casts()`) - Modify: `app/Services/Billing/StorageAllowance.php:41-113` (gebuchte Größen statt Konfiguration) - Modify: `app/Services/Billing/AddonCatalogue.php` (eine Stelle, die die Konfiguration liest) - Modify: `app/Actions/BookAddon.php:146-155` (Größen beim Buchen einfrieren) - Test: `tests/Feature/Billing/StoragePackSizeTest.php` **Interfaces:** - Produces: `AddonCatalogue::packGb(): int`, `AddonCatalogue::packDiskGb(): int` (heutige Größen aus der Konfiguration, für **neue** Buchungen). - Produces: `StorageAllowance::packGb(): int` (unverändert im Namen, neu in der Bedeutung: Summe der **eingefrorenen** Größen), `StorageAllowance::packDiskGb(): int` (was die Blöcke auf dem Host belegen). - Produces: Spalten `subscription_addons.pack_gb`, `subscription_addons.pack_disk_gb`. - Consumes: nichts. - [ ] **Step 1: Den fehlschlagenden Test schreiben** `tests/Feature/Billing/StoragePackSizeTest.php`: ```php create(['plan' => $plan, 'datacenter' => 'fsn', 'status' => 'paid']); return app(OpenSubscription::class)($order); } it('friert die Packungsgröße auf der Buchung ein', function () { config(['provisioning.storage_addon.gb' => 100, 'provisioning.storage_addon.disk_gb' => 100]); $subscription = packContract(); app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1); // Der Betreiber schneidet das Paket neu zu. config(['provisioning.storage_addon.gb' => 20, 'provisioning.storage_addon.disk_gb' => 22]); $allowance = StorageAllowance::forPlan(85, $subscription->refresh()); expect($allowance->packGb())->toBe(100) ->and($allowance->packDiskGb())->toBe(100) ->and($allowance->totalGb())->toBe(185); }); it('bucht neue Blöcke zur heutigen Größe', function () { config(['provisioning.storage_addon.gb' => 20, 'provisioning.storage_addon.disk_gb' => 22]); $subscription = packContract(); app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 2); $allowance = StorageAllowance::forPlan(30, $subscription->refresh()); expect($allowance->packs)->toBe(2) ->and($allowance->packGb())->toBe(40) ->and($allowance->packDiskGb())->toBe(44); }); it('lässt die eingefrorene Größe nicht nachträglich ändern', function () { config(['provisioning.storage_addon.gb' => 100, 'provisioning.storage_addon.disk_gb' => 100]); $subscription = packContract(); $addon = app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1); expect(fn () => $addon->update(['pack_gb' => 20])) ->toThrow(RuntimeException::class); expect(SubscriptionAddon::query()->whereKey($addon->id)->value('pack_gb'))->toBe(100); }); ``` - [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen** Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Billing/StoragePackSizeTest.php` Erwartet: FEHLSCHLAG — `Call to undefined method App\Services\Billing\StorageAllowance::packDiskGb()`. - [ ] **Step 3: Migration schreiben** `database/migrations/2026_08_01_000001_add_pack_size_to_subscription_addons.php`: ```php unsignedInteger('pack_gb')->default(0)->after('quantity'); $table->unsignedInteger('pack_disk_gb')->default(0)->after('pack_gb'); }); DB::table('subscription_addons') ->where('addon_key', 'storage') ->update(['pack_gb' => 100, 'pack_disk_gb' => 100]); } public function down(): void { Schema::table('subscription_addons', function (Blueprint $table) { $table->dropColumn(['pack_gb', 'pack_disk_gb']); }); } }; ``` - [ ] **Step 4: Die heutige Größe an eine Stelle legen** In `app/Services/Billing/AddonCatalogue.php`, hinter `priceCents()`: ```php /** * Wie groß ein Block ist, den jemand HEUTE kauft. * * Nur für neue Buchungen. Was ein bereits gekaufter Block wiegt, steht auf * der Buchung — siehe StorageAllowance. */ public function packGb(): int { return (int) config('provisioning.storage_addon.gb', 0); } /** * Was ein Block auf dem Host belegt: die nutzbaren Gigabyte plus den * Kopfraum, den auch ein gestapeltes Paket braucht. Ohne eigene Angabe so * groß wie das Nutzbare — das war der Zustand vor dem Kopfraum. */ public function packDiskGb(): int { return (int) config('provisioning.storage_addon.disk_gb', $this->packGb()); } ``` - [ ] **Step 5: `StorageAllowance` auf die Buchungen umstellen** In `app/Services/Billing/StorageAllowance.php` den Konstruktor, `forPlan()`, `bookedPacks()`, `packGb()` und `packSizeGb()` ersetzen: ```php public function __construct( /** Was das Paket selbst mitbringt. */ public int $planGb, /** Wie viele Blöcke laufen — die Zahl, die der Kunde liest. */ public int $packs, /** Was diese Blöcke dem Kunden geben, zu den Größen ihrer Buchung. */ public int $bookedGb, /** Was sie auf dem Host belegen, zu den Größen ihrer Buchung. */ public int $bookedDiskGb, /** Wie groß ein Block HEUTE wäre — für Angebote, nicht für Bestand. */ public int $packSizeGb, ) {} public static function forPlan(int $planGb, ?Subscription $subscription): self { $booked = self::bookedPacks($subscription); return new self( max(0, $planGb), $booked['packs'], $booked['gb'], $booked['disk_gb'], app(AddonCatalogue::class)->packGb(), ); } /** * Die laufenden Blöcke mit ihren eigenen Größen. * * Frisch abgefragt und nicht über eine geladene Beziehung, aus demselben * Grund wie bisher: gefragt wird direkt nach einer Buchung oder Kündigung, * und eine vorher geladene Beziehung antwortete mit dem Zustand davor. * * @return array{packs: int, gb: int, disk_gb: int} */ private static function bookedPacks(?Subscription $subscription): array { if ($subscription === null) { return ['packs' => 0, 'gb' => 0, 'disk_gb' => 0]; } $rows = $subscription->addons()->active() ->where('addon_key', AddonCatalogue::STORAGE) ->get(['quantity', 'pack_gb', 'pack_disk_gb']); return [ 'packs' => (int) $rows->sum('quantity'), 'gb' => (int) $rows->sum(fn ($row) => (int) $row->quantity * (int) $row->pack_gb), 'disk_gb' => (int) $rows->sum(fn ($row) => (int) $row->quantity * (int) $row->pack_disk_gb), ]; } /** Alles, was die gebuchten Blöcke dem Kunden geben. */ public function packGb(): int { return $this->bookedGb; } /** Alles, was die gebuchten Blöcke auf dem Host belegen. */ public function packDiskGb(): int { return $this->bookedDiskGb; } ``` Die private Methode `packSizeGb()` am Dateiende entfällt ersatzlos — ihr Kopfkommentar beschreibt genau den Zustand, den dieser Schritt beendet. `packsToCover()` bleibt unverändert: wie viele Blöcke jemand NOCH kaufen müsste, ist eine Frage über die heutige Größe. - [ ] **Step 6: Beim Buchen einfrieren** In `app/Actions/BookAddon.php`, im `SubscriptionAddon::create([...])` innerhalb von `book()`, hinter `'quantity' => $quantity,`: ```php // Was dieser Block wiegt, zum Zeitpunkt des Kaufs. Ohne diese // beiden Zahlen wäre die Größe wieder eine Ansicht. 'pack_gb' => $addonKey === AddonCatalogue::STORAGE ? $catalogue->packGb() : 0, 'pack_disk_gb' => $addonKey === AddonCatalogue::STORAGE ? $catalogue->packDiskGb() : 0, ``` - [ ] **Step 7: Die Größen gegen nachträgliche Änderung sperren** In `app/Models/SubscriptionAddon.php` die Konstante erweitern: ```php /** What the customer agreed to. */ public const FROZEN = ['subscription_id', 'addon_key', 'price_cents', 'currency', 'quantity', 'booked_at', 'pack_gb', 'pack_disk_gb']; ``` und in `casts()` ergänzen: ```php 'pack_gb' => 'integer', 'pack_disk_gb' => 'integer', ``` - [ ] **Step 8: Tests laufen lassen** Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Billing/StoragePackSizeTest.php tests/Feature/Billing/StorageAllowanceTest.php tests/Feature/DowngradeTest.php` Erwartet: BESTANDEN. - [ ] **Step 9: Ganze Suite laufen lassen** Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test` Erwartet: BESTANDEN. Schlägt etwas fehl, liegt es an einem Aufrufer von `StorageAllowance::__construct()` — such ihn mit `grep -rn "new StorageAllowance" app/ tests/`. - [ ] **Step 10: Commit** ```bash git add database/migrations app/Models/SubscriptionAddon.php app/Services/Billing app/Actions/BookAddon.php tests/Feature/Billing/StoragePackSizeTest.php git commit -m "Die Packungsgroesse steht auf der Buchung, nicht in der Konfiguration Bisher las StorageAllowance sie bei jeder Anzeige neu aus config. Solange es eine Groesse gab, war das folgenlos — beim Zuschnitt von 100 GB auf 20 GB waere es der stille Verlust von 80 GB je gekauftem Block gewesen, bei einem Kunden, der bereits Daten darin liegen hat. Co-Authored-By: Claude Opus 5 " ``` --- ### Task 2: Der Deckel bei drei Blöcken gehört in die Buchung `BookAddon` kennt heute keine Obergrenze; `Billing::MAX_STORAGE_PACKS` steht bei 50 und ist eine Sicherung gegen Unsinn, keine kaufmännische Grenze. Ohne Deckel in der Aktion kann ein Kunde 50 Blöcke in den Warenkorb legen, bezahlen — und die Buchung scheitert danach. **Files:** - Modify: `config/provisioning.php:318` (`max`) - Modify: `app/Services/Billing/AddonCatalogue.php` (`maxQuantity()`, `quantityRefusal()`) - Modify: `app/Actions/BookAddon.php:79-96` (Prüfung vor dem Buchen) - Modify: `app/Livewire/Billing.php:43,178-180` (Warenkorb respektiert den Deckel) - Modify: `app/Livewire/ConfirmBookStorage.php:30-34` (Klemmung auf den Deckel) - Modify: `lang/de/billing.php`, `lang/en/billing.php` - Test: `tests/Feature/Billing/StoragePackLimitTest.php` **Interfaces:** - Consumes: `AddonCatalogue::packGb()` aus Task 1. - Produces: `AddonCatalogue::maxQuantity(string $key): ?int`, `AddonCatalogue::quantityRefusal(?Subscription $subscription, string $key, int $quantity): ?string`, `AddonCatalogue::bookableQuantity(?Subscription $subscription, string $key): int`. - [ ] **Step 1: Den fehlschlagenden Test schreiben** `tests/Feature/Billing/StoragePackLimitTest.php`: ```php create(['plan' => $plan, 'datacenter' => 'fsn', 'status' => 'paid']); return app(OpenSubscription::class)($order); } it('lehnt den vierten Block auf einmal ab', function () { $subscription = limitContract(); expect(fn () => app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 4)) ->toThrow(RuntimeException::class); expect($subscription->addons()->count())->toBe(0); }); it('zählt drei einzelne Buchungen als drei', function () { $subscription = limitContract(); foreach (range(1, 3) as $ignored) { app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1, App\Models\Order::factory()->create([ 'customer_id' => $subscription->customer_id, 'type' => 'addon', 'addon_key' => 'storage', 'status' => 'paid', ])); } expect(fn () => app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1, App\Models\Order::factory()->create([ 'customer_id' => $subscription->customer_id, 'type' => 'addon', 'addon_key' => 'storage', 'status' => 'paid', ])))->toThrow(RuntimeException::class); expect((int) $subscription->addons()->active()->sum('quantity'))->toBe(3); }); it('zählt eine gekündigte Buchung nicht mit', function () { $subscription = limitContract(); $addon = app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 3); app(BookAddon::class)->cancel($addon); expect(app(AddonCatalogue::class)->bookableQuantity($subscription->refresh(), AddonCatalogue::STORAGE))->toBe(3); }); it('sagt, wie viele noch gehen', function () { $subscription = limitContract(); app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 2); expect(app(AddonCatalogue::class)->bookableQuantity($subscription->refresh(), AddonCatalogue::STORAGE))->toBe(1); }); ``` - [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen** Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Billing/StoragePackLimitTest.php` Erwartet: FEHLSCHLAG — der vierte Block wird gebucht, `toThrow` schlägt fehl. - [ ] **Step 3: Deckel in die Konfiguration** In `config/provisioning.php`, die Zeile `'storage_addon' => …` ersetzen: ```php // Der Deckel ist kaufmännisch, nicht technisch: er liegt dort, wo Aufsteigen // billiger wird als Stapeln. Ohne ihn belegt der günstigste Tarif den // knappsten Rohstoff. Gelesen von AddonCatalogue::maxQuantity(), geprüft in // BookAddon — im Formular steht er nur zusätzlich. 'storage_addon' => ['gb' => 100, 'price_cents' => 1000, 'sold_as' => 'quantity', 'max' => 3], ``` (Größe und Preis ändert Task 5 — hier nur der Deckel, damit jede Aufgabe für sich lauffähig bleibt.) - [ ] **Step 4: Die Regel in `AddonCatalogue`** In `app/Services/Billing/AddonCatalogue.php`, hinter `duplicateRefusal()`: ```php /** Wie viele Einheiten ein Vertrag von diesem Modul halten darf, oder null. */ public function maxQuantity(string $key): ?int { $max = $this->definition($key)['max'] ?? null; return $max === null ? null : max(1, (int) $max); } /** Wie viele davon dieser Vertrag jetzt noch buchen könnte. */ public function bookableQuantity(?Subscription $subscription, string $key): int { $max = $this->maxQuantity($key); if ($max === null) { return PHP_INT_MAX; } return max(0, $max - $this->bookedQuantity($subscription, $key)); } /** * Warum diese Menge nicht gebucht werden darf. Null, wenn sie darf. * * In dem Satz, den der Kunde liest — die Aktion weist damit ab, und die * Ansicht fragt damit, bevor sie einen Knopf anbietet. Zwei Formulierungen * derselben Grenze wären zwei Grenzen. */ public function quantityRefusal(?Subscription $subscription, string $key, int $quantity): ?string { $max = $this->maxQuantity($key); if ($max === null || $this->bookedQuantity($subscription, $key) + $quantity <= $max) { return null; } return __('billing.addon_limit_reached', ['module' => $this->name($key), 'max' => $max]); } /** Wie viele Einheiten dieses Moduls auf dem Vertrag laufen. */ private function bookedQuantity(?Subscription $subscription, string $key): int { if ($subscription === null) { return 0; } return (int) $subscription->addons()->active()->where('addon_key', $key)->sum('quantity'); } ``` - [ ] **Step 5: Die Prüfung in `BookAddon`** In `app/Actions/BookAddon.php`, in `__invoke()` direkt hinter der `$refusal`-Prüfung für die eigene Domain: ```php // Der Deckel, an der Stelle, die ihn halten muss. Gezählt wird über // ALLE laufenden Buchungen dieses Moduls, nicht je Buchung: drei // Bestellungen à einem Block sind drei Blöcke, und eine Prüfung je // Bestellung hätte jede einzeln durchgewinkt. $overLimit = app(AddonCatalogue::class)->quantityRefusal($subscription, $addonKey, $quantity); if ($overLimit !== null) { throw new RuntimeException($overLimit); } ``` - [ ] **Step 6: Warenkorb und Modal an denselben Deckel binden** In `app/Livewire/Billing.php` die Konstante ersetzen und die Klemmung ändern: ```php /** * Notbremse gegen eine unsinnige Zahl aus dem Formular. Die kaufmännische * Grenze steht in AddonCatalogue::maxQuantity() und wird von BookAddon * gehalten — hier wird nur verhindert, dass jemand für etwas bezahlt, das * die Buchung danach ablehnt. */ private const MAX_STORAGE_PACKS = 50; ``` und in `purchase()`: ```php $lines = $type === 'storage' ? max(1, min(self::MAX_STORAGE_PACKS, app(AddonCatalogue::class)->bookableQuantity($contract, AddonCatalogue::STORAGE), $quantity)) : 1; ``` In `app/Livewire/ConfirmBookStorage.php`, `mount()`: ```php public function mount(int $packs = 1): void { $catalogue = app(AddonCatalogue::class); $max = $catalogue->maxQuantity(AddonCatalogue::STORAGE) ?? 50; // Geklemmt wie bisher, nur an der kaufmännischen Grenze statt an einer // erfundenen: ein Modal wird mit Argumenten aus der Seite geöffnet, und // eine Seite ist Markup. $this->packs = max(1, min($max, $packs)); $this->packGb = $catalogue->packGb(); } ``` (`use App\Services\Billing\AddonCatalogue;` in beiden Dateien ergänzen.) - [ ] **Step 7: Die Sätze** In `lang/de/billing.php`, bei den übrigen `addon_`-Schlüsseln: ```php 'addon_limit_reached' => ':module ist auf :max Einheiten begrenzt. Ein größeres Paket bietet mehr Speicher für weniger Geld.', ``` In `lang/en/billing.php`: ```php 'addon_limit_reached' => ':module is limited to :max units. A larger package gives you more storage for less money.', ``` - [ ] **Step 8: Tests laufen lassen** Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Billing/StoragePackLimitTest.php tests/Feature/CartTest.php tests/Feature/DowngradeTest.php` Erwartet: BESTANDEN. `DowngradeTest` kann Blöcke über dem Deckel anbieten — wenn dort etwas fehlschlägt, ist die erwartete Zahl anzupassen, nicht der Deckel. - [ ] **Step 9: Ganze Suite und Commit** ```bash docker compose exec -T -e HOME=/tmp -u www-data app php artisan test git add config/provisioning.php app/Services/Billing/AddonCatalogue.php app/Actions/BookAddon.php app/Livewire/Billing.php app/Livewire/ConfirmBookStorage.php lang tests/Feature/Billing/StoragePackLimitTest.php git commit -m "Hoechstens drei Bloecke, geprueft in der Buchung Die Ansicht durfte 50 in den Warenkorb legen, die Aktion pruefte nichts. Der Deckel liegt kaufmaennisch dort, wo Aufsteigen billiger wird als Stapeln, und gehoert deshalb dorthin, wo gebucht wird — gezaehlt ueber alle laufenden Buchungen, denn drei Bestellungen a einem Block sind drei Bloecke. Co-Authored-By: Claude Opus 5 " ``` --- ### Task 3: Das Buchen muss den Host fragen Neun Startkunden belegen 360 GB; drei Blöcke je Kunde wären 594 GB obendrauf, auf einer Maschine, die 388 GB vergibt. Weil qcow2 dünn belegt ist, **gelingt** die Überbuchung sofort und fällt erst auf, wenn die Gäste wirklich schreiben. **Files:** - Modify: `app/Actions/BookAddon.php` (Kapazitätsprüfung vor dem Buchen) - Modify: `lang/de/billing.php`, `lang/en/billing.php` - Test: `tests/Feature/Billing/StoragePackCapacityTest.php` **Interfaces:** - Consumes: `AddonCatalogue::packDiskGb()` (Task 1), `Host::canTake(int $quotaGb): bool` (vorhanden), `Subscription::instance(): BelongsTo` (vorhanden). - Produces: nichts Neues. - [ ] **Step 1: Den fehlschlagenden Test schreiben** `tests/Feature/Billing/StoragePackCapacityTest.php`: ```php 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); }); ``` **Hinweis für den Umsetzenden:** `Host::factory()` und die Spalte für den Gesamtspeicher gegen `database/factories/HostFactory.php` und `Host::availableGb()` prüfen; der Test muss die echten Namen benutzen, nicht diese hier vermuteten. Ebenso `Subscription::instance()` — falls die Verknüpfung über `instances.order_id` läuft, ist die Instanz so anzulegen. - [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen** Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Billing/StoragePackCapacityTest.php` Erwartet: FEHLSCHLAG — der erste Test bucht durch, `toThrow` schlägt fehl. - [ ] **Step 3: Die Prüfung einbauen** In `app/Actions/BookAddon.php`, in `__invoke()` hinter der Deckelprüfung aus Task 2: ```php // 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')); } } ``` - [ ] **Step 4: Der Satz** `lang/de/billing.php`: ```php '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.', ``` `lang/en/billing.php`: ```php 'storage_no_room' => 'Your server currently has no room for more storage. Please contact support — we will make room.', ``` - [ ] **Step 5: Tests und Commit** ```bash docker compose exec -T -e HOME=/tmp -u www-data app php artisan test git add app/Actions/BookAddon.php lang tests/Feature/Billing/StoragePackCapacityTest.php git commit -m "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. Co-Authored-By: Claude Opus 5 " ``` --- ### Task 4: Der Kopfraum der Blöcke gehört auf die Platte `ResizeVirtualMachine` legt einen Block heute voll auf das Kontingent und lässt den Kopfraum des Pakets unverändert mitfahren. Start + 3 Blöcke wären damit 90 GB auf einer 100-GB-Platte — 10 GB Kopfraum, wo die Regel bei dieser Plattengröße zwölf verlangt. **Files:** - Modify: `app/Provisioning/Steps/Customer/ResizeVirtualMachine.php:88-125` - Test: `tests/Feature/Provisioning/StoragePackHeadroomTest.php` **Interfaces:** - Consumes: `StorageAllowance::totalGb()`, `StorageAllowance::packDiskGb()`, `StorageAllowance::packGb()` (Task 1). - [ ] **Step 1: Den fehlschlagenden Test schreiben** `tests/Feature/Provisioning/StoragePackHeadroomTest.php`: ```php totalGb() + max(0, $planDisk - $planQuota) + ($allowance->packDiskGb() - $allowance->packGb()); expect($target)->toBe(106); }); ``` Dazu ein Test, der den Schritt selbst fährt — dem Muster von `tests/Feature/Provisioning/CustomerStepsTest.php` folgen (dort steht, wie ein `ProvisioningRun` mit `FakeProxmoxClient` aufgesetzt wird) und prüfen, dass `resizeDisk` mit `106G` aufgerufen wird und `instances.disk_gb` danach 106 ist. - [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen** Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Provisioning/StoragePackHeadroomTest.php` Erwartet: FEHLSCHLAG — `packDiskGb()` gibt es zwar (Task 1), aber der Schritt rechnet ohne sie und kommt auf 100. - [ ] **Step 3: Die Rechnung ändern** In `app/Provisioning/Steps/Customer/ResizeVirtualMachine.php`, in `growDisk()`: ```php $allowance = StorageAllowance::for($instance); // Kontingent + Blöcke + Kopfraum des Pakets + Kopfraum der Blöcke. // // Der letzte Summand ist der neue: ein Block gibt dem Kunden 20 GB und // belegt 22, damit die Regel max(10 GB, 12 %) auch für ein gestapeltes // Paket gilt. Ohne ihn hätte ein Start mit drei Blöcken 90 GB auf 100 GB // Platte — zehn Gigabyte Kopfraum, wo die Regel zwölf verlangt. // // Beide Kopfräume werden gelesen, nicht abgeleitet: der des Pakets aus // dem eingefrorenen Vertrag, der der Blöcke aus den Buchungen. $target = $allowance->totalGb() > 0 ? $allowance->totalGb() + max(0, $planDisk - $planQuota) + max(0, $allowance->packDiskGb() - $allowance->packGb()) : $planDisk; ``` - [ ] **Step 4: Tests laufen lassen** Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Provisioning` Erwartet: BESTANDEN. - [ ] **Step 5: Commit** ```bash git add app/Provisioning/Steps/Customer/ResizeVirtualMachine.php tests/Feature/Provisioning/StoragePackHeadroomTest.php git commit -m "Ein Block bringt seinen eigenen Kopfraum mit Ein Block gibt 20 GB und belegt 22. Ohne das haette ein Start mit drei Bloecken 90 GB auf 100 GB Platte gehabt — zehn Gigabyte Kopfraum, wo die Regel bei dieser Plattengroesse zwoelf verlangt, und der gestapelte Tarif waere genau der Fall geworden, den die Regel verhindern soll. Co-Authored-By: Claude Opus 5 " ``` --- ### Task 5: Der neue Blockpreis, und nicht bei Enterprise Erst jetzt — nach Task 1 — darf die Konfiguration sich bewegen. **Files:** - Modify: `config/provisioning.php` (`storage_addon`) - Modify: `app/Services/Billing/AddonCatalogue.php` (`availabilityRefusal()`) - Modify: `app/Actions/BookAddon.php` (Ausschluss prüfen) - Modify: `lang/de/billing.php`, `lang/en/billing.php` (Texte mit neuen Zahlen) - Test: `tests/Feature/Billing/StoragePackPricingTest.php` **Interfaces:** - Produces: `AddonCatalogue::availabilityRefusal(?Subscription $subscription, string $key): ?string`. - [ ] **Step 1: Den fehlschlagenden Test schreiben** `tests/Feature/Billing/StoragePackPricingTest.php`: ```php packGb())->toBe(20) ->and($catalogue->packDiskGb())->toBe(22) ->and($catalogue->priceCents(AddonCatalogue::STORAGE))->toBe(1500) ->and($catalogue->maxQuantity(AddonCatalogue::STORAGE))->toBe(3); }); it('bleibt teurer je Gigabyte als der Aufstieg', function () { // Gerechnet wird mit dem, was Katalog und Konfiguration WIRKLICH sagen — // mit festen Zahlen prüfte dieser Test nur, ob PHP dividieren kann. So // schlägt er an, wenn ein Preis später einmal so gesetzt wird, dass // Stapeln sich wieder lohnt. $catalogue = app(AddonCatalogue::class); $plans = app(App\Services\Billing\PlanCatalogue::class)->sellable(); $blockPerGb = $catalogue->priceCents(AddonCatalogue::STORAGE) / $catalogue->packGb(); foreach ([['start', 'team'], ['team', 'business']] as [$von, $nach]) { $mehrGb = $plans[$nach]['quota_gb'] - $plans[$von]['quota_gb']; $mehrCent = $plans[$nach]['price_cents'] - $plans[$von]['price_cents']; expect($blockPerGb)->toBeGreaterThan($mehrCent / $mehrGb); } }); it('bietet Enterprise keinen Zusatzspeicher an', function () { $order = Order::factory()->create(['plan' => 'enterprise', 'datacenter' => 'fsn', 'status' => 'paid']); $subscription = app(OpenSubscription::class)($order); expect(fn () => app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1)) ->toThrow(RuntimeException::class); }); ``` - [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen** Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Billing/StoragePackPricingTest.php` Erwartet: FEHLSCHLAG — `packGb()` gibt 100 zurück. - [ ] **Step 3: Konfiguration umstellen** In `config/provisioning.php`: ```php /* | Ein Block Zusatzspeicher: 20 GB für den Kunden, 22 GB auf der Platte. | | Die zwei Gigabyte Unterschied sind Kopfraum. Ein Paket bringt seinen | eigenen mit (max(10 GB, 12 %) seiner Platte); ohne denselben Aufschlag am | Block hätte ein gestapeltes Paket weniger Luft als ein gekauftes gleicher | Größe, und eine volle Platte legt die Instanz still, nicht nur den Upload. | | Der Preis folgt einer Regel, nicht einem Gefühl: ein Block muss teurer je | GB sein als der Aufstieg in die nächste Stufe (0,75 gegen 0,73 und 0,67), | sonst stapeln Kunden Zusätze, statt aufzusteigen, und belegen den | knappsten Rohstoff zum niedrigsten Preis. `max` deckelt das zusätzlich | dort, wo Aufsteigen billiger UND besser wird. | | `unavailable_on` nennt die Pakete, für die es das Modul nicht gibt — bei | Enterprise, weil dort eine eigene Maschine verkauft wird und ein 20-GB- | Block darauf keine Antwort ist. */ 'storage_addon' => [ 'gb' => 20, 'disk_gb' => 22, 'price_cents' => 1500, 'sold_as' => 'quantity', 'max' => 3, 'unavailable_on' => ['enterprise'], ], ``` - [ ] **Step 4: Den Ausschluss halten** In `app/Services/Billing/AddonCatalogue.php`: ```php /** * Warum es dieses Modul für dieses Paket gar nicht gibt. Null, wenn es * das gibt. * * Nicht dasselbe wie "schon gebucht" oder "Deckel erreicht": hier ist das * Modul auf diesem Paket nicht vorgesehen. Genannt wird das Paket beim * Schlüssel, wie bei der eigenen Domain — der Katalog beschreibt, was ein * Paket HAT, nicht was es kaufen darf. */ public function availabilityRefusal(?Subscription $subscription, string $key): ?string { $closed = (array) ($this->definition($key)['unavailable_on'] ?? []); if ($subscription === null || ! in_array((string) $subscription->plan, $closed, true)) { return null; } return __('billing.addon_not_available', ['module' => $this->name($key)]); } ``` In `app/Actions/BookAddon.php`, vor der Deckelprüfung: ```php $closed = app(AddonCatalogue::class)->availabilityRefusal($subscription, $addonKey); if ($closed !== null) { throw new RuntimeException($closed); } ``` - [ ] **Step 5: Sätze und Zahlen in den Texten** `lang/de/billing.php`: ```php 'addon_not_available' => ':module ist für Ihr Paket nicht vorgesehen.', ``` `lang/en/billing.php`: ```php 'addon_not_available' => ':module is not offered for your package.', ``` Die vorhandenen Schlüssel `storage_body`, `storage_cta`, `storage_confirm_body` und `cart.storage` tragen `:gb` und `:price` bereits als Platzhalter — prüfen, dass keiner davon eine feste 100 im Text stehen hat, und gegebenenfalls ändern. - [ ] **Step 6: Tests und Commit** ```bash docker compose exec -T -e HOME=/tmp -u www-data app php artisan test git add config/provisioning.php app/Services/Billing/AddonCatalogue.php app/Actions/BookAddon.php lang tests/Feature/Billing/StoragePackPricingTest.php git commit -m "Zusatzspeicher: 20 GB fuer 15 Euro, hoechstens drei, nicht bei Enterprise 100 GB fuer 10 Euro waren 0,10 Euro/GB — unter Einstandspreis und billiger als jeder Aufstieg. Wer stapelt, belegt dann den knappsten Rohstoff zum niedrigsten Preis. 0,75 Euro/GB liegen ueber beiden Aufstiegen (0,73 und 0,67), und der Deckel bei drei liegt dort, wo Aufsteigen billiger UND besser wird. Co-Authored-By: Claude Opus 5 " ``` --- ### Task 6: Das interne Paket **Files:** - Create: `database/migrations/2026_08_01_000002_add_internal_to_plan_families.php` - Modify: `app/Models/PlanFamily.php` (`casts()`) - Modify: `app/Services/Billing/PlanCatalogue.php:38-45` (`sellable()`) - Modify: `app/Http/Controllers/CheckoutController.php:95-105` - Modify: `app/Livewire/Admin/Plans.php` + `resources/views/livewire/admin/plans.blade.php` (Kennzeichen) - Modify: `lang/de/admin.php`, `lang/en/admin.php` - Test: `tests/Feature/Billing/InternalPlanTest.php` **Interfaces:** - Produces: Spalte `plan_families.internal` (boolean, Vorgabe `false`). - Produces: `PlanCatalogue::sellable()` überspringt interne Familien; `PlanCatalogue::currentVersion()` liefert sie weiterhin. - [ ] **Step 1: Den fehlschlagenden Test schreiben** `tests/Feature/Billing/InternalPlanTest.php`: ```php where('key', 'test')->update(['internal' => true]); }); it('zeigt ein internes Paket nicht im Verkauf', function () { expect(array_keys(app(PlanCatalogue::class)->sellable()))->not->toContain('test'); }); it('lässt ein internes Paket weiterhin verschenken', function () { expect(app(PlanCatalogue::class)->currentVersion('test')->quota_gb)->toBeInt(); }); it('lehnt einen Checkout auf ein internes Paket ab', function () { $response = $this->post(route('checkout.start'), ['plan' => 'test', 'term' => 'monthly', 'datacenter' => 'fsn']); $response->assertStatus(404); }); ``` **Hinweis:** Route- und Feldnamen des Checkouts gegen `app/Http/Controllers/CheckoutController.php` und `routes/web.php` prüfen; der Test muss die echten benutzen. Erwartet wird eine Ablehnung — welcher Statuscode das ist, richtet sich danach, wie der Controller heute einen unbekannten Schlüssel behandelt (dieselbe Antwort ist die richtige). - [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen** Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Billing/InternalPlanTest.php` Erwartet: FEHLSCHLAG — Spalte `internal` existiert nicht. - [ ] **Step 3: Migration** `database/migrations/2026_08_01_000002_add_internal_to_plan_families.php`: ```php boolean('internal')->default(false)->after('sales_enabled'); }); } public function down(): void { Schema::table('plan_families', function (Blueprint $table) { $table->dropColumn('internal'); }); } }; ``` In `app/Models/PlanFamily.php`, `casts()` ergänzen: `'internal' => 'boolean',`. - [ ] **Step 4: `sellable()` filtert** In `app/Services/Billing/PlanCatalogue.php`, in `sellable()`: ```php return PlanFamily::query() ->where('sales_enabled', true) // Interne Pakete sind kein Angebot. Sie hier auszulassen genügt für // Preisblatt, Bestellung und Warenkorb, die alle diese eine Liste // lesen — currentVersion() liefert sie weiterhin, sonst wäre das // Verschenken mit abgeschaltet. ->where('internal', false) ->with(['versions' => fn ($q) => $q->available($at)->with('prices')]) ->orderBy('tier') ``` - [ ] **Step 5: Der Checkout lehnt ab** In `app/Http/Controllers/CheckoutController.php`, vor `$version = $catalogue->currentVersion($data['plan']);`: ```php // Die einzige Stelle, an der ein Paketschlüssel VON AUSSEN in // currentVersion() läuft. Dass ein internes Paket in keiner Liste // steht, ist kein Schutz — eine URL ist keine Liste. if (PlanFamily::query()->where('key', $data['plan'])->value('internal')) { abort(404); } ``` (Import `use App\Models\PlanFamily;` ergänzen. Statuscode an die vorhandene Behandlung eines unbekannten Schlüssels angleichen.) - [ ] **Step 6: In der Konsole sichtbar machen** In `resources/views/livewire/admin/plans.blade.php` neben dem Namen ein Kennzeichen zeigen, wenn `$family->internal` — Muster und Klassen von der vorhandenen Kennzeichnung „nicht im Verkauf" übernehmen, Text aus `lang/*/admin.php` (`plans.internal` = „Intern" / „Internal"). - [ ] **Step 7: Tests und Commit** ```bash docker compose exec -T -e HOME=/tmp -u www-data app php artisan test git add database/migrations app/Models/PlanFamily.php app/Services/Billing/PlanCatalogue.php app/Http/Controllers/CheckoutController.php app/Livewire/Admin resources/views/livewire/admin/plans.blade.php lang tests/Feature/Billing/InternalPlanTest.php git commit -m "Interne Pakete verschwinden aus dem Laden und bleiben in der Konsole sales_enabled waere der falsche Hebel gewesen: es schaltet auch das Verschenken ab. Das Testpaket ist fuer Abnahmelaeufe da und muss verschenkbar bleiben, also zwei Felder fuer zwei Fragen. Der Checkout lehnt einen internen Schluessel ausdruecklich ab — eine URL ist keine Liste. Co-Authored-By: Claude Opus 5 " ``` --- ### Task 7: Die Host-Reservierung Ohne sie ist „eigener Server" nur ein Wort: `placeableIn()` nimmt jeden aktiven Host, und `hasRoomFor()`/`largestPlaceableGb()` **zählen** die exklusiv verkaufte Maschine obendrein mit. **Files:** - Create: `database/migrations/2026_08_01_000003_add_reservation_to_hosts.php` - Modify: `app/Models/Host.php:228-241` (`placeableIn()`, Scope) - Modify: `app/Services/Provisioning/HostCapacity.php:30-49` (reservierte Hosts auslassen) - Modify: `app/Provisioning/Steps/Customer/ReserveResources.php:95-96` (Kunde mitgeben) - Modify: `app/Livewire/Admin/HostDetail.php` + Blade (setzen und lösen) - Modify: `lang/de/hosts.php`, `lang/en/hosts.php` - Test: `tests/Feature/Admin/HostReservationTest.php` **Interfaces:** - Produces: Spalte `hosts.reserved_for_customer_id` (nullable, Fremdschlüssel). - Produces: `Host::placeableIn(string $datacenter, int $quotaGb, ?int $forCustomerId = null): ?self` — **die Signatur ändert sich**; alle Aufrufer sind `HostCapacity::canPlace()` und `ReserveResources`. - Produces: `Host::scopeUnreserved(Builder $query, ?int $forCustomerId = null)`. - [ ] **Step 1: Den fehlschlagenden Test schreiben** `tests/Feature/Admin/HostReservationTest.php`: ```php create(); $fremder = Customer::factory()->create(); Host::factory()->active()->create([ 'datacenter' => 'fsn', 'total_gb' => 4000, 'reserved_for_customer_id' => $mieter->id, ]); expect(Host::placeableIn('fsn', 100, $fremder->id))->toBeNull() ->and(Host::placeableIn('fsn', 100, $mieter->id))->not->toBeNull(); }); it('zählt einen reservierten Host nicht zum allgemeinen Bestand', function () { $mieter = Customer::factory()->create(); Host::factory()->active()->create([ 'datacenter' => 'fsn', 'total_gb' => 4000, 'reserved_for_customer_id' => $mieter->id, ]); expect(app(HostCapacity::class)->largestPlaceableGb('fsn'))->toBe(0) ->and(app(HostCapacity::class)->canPlace('fsn', 40))->toBeFalse(); }); it('lässt einen freien Host frei', function () { Host::factory()->active()->create(['datacenter' => 'fsn', 'total_gb' => 500]); expect(app(HostCapacity::class)->canPlace('fsn', 40))->toBeTrue(); }); ``` - [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen** Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Admin/HostReservationTest.php` Erwartet: FEHLSCHLAG — Spalte fehlt. - [ ] **Step 3: Migration** ```php foreignId('reserved_for_customer_id')->nullable()->after('datacenter') ->constrained('customers')->nullOnDelete(); }); } public function down(): void { Schema::table('hosts', function (Blueprint $table) { $table->dropConstrainedForeignId('reserved_for_customer_id'); }); } }; ``` - [ ] **Step 4: Platzierung und Kapazität** In `app/Models/Host.php`: ```php /** * Placement (spec §1): first active host in the datacenter with enough free * committable storage. Cluster is ignored in v1.0. * * Reservierte Hosts gehören ihrem Kunden. Ohne den dritten Parameter ist * die Frage "wer aus dem allgemeinen Bestand kann das tragen" — und die * Antwort darf keine Maschine sein, die bereits jemandem versprochen ist. */ public static function placeableIn(string $datacenter, int $quotaGb, ?int $forCustomerId = null): ?self { return self::query() ->where('datacenter', $datacenter) ->where('status', 'active') ->unreserved($forCustomerId) ->orderBy('name') ->get() ->first(fn (self $host) => $host->availableGb() >= $quotaGb); } /** * Hosts, die für diese Platzierung in Frage kommen: der allgemeine Bestand * und die eigenen. */ public function scopeUnreserved(Builder $query, ?int $forCustomerId = null): Builder { return $query->where(fn (Builder $q) => $q ->whereNull('reserved_for_customer_id') ->when($forCustomerId !== null, fn (Builder $inner) => $inner->orWhere('reserved_for_customer_id', $forCustomerId))); } ``` In `app/Services/Provisioning/HostCapacity.php`: ```php public function canPlace(string $datacenter, int $diskGb): bool { return Host::placeableIn($datacenter, $diskGb) !== null; } public function largestPlaceableGb(?string $datacenter = null): int { return (int) Host::query() ->where('status', 'active') // Der allgemeine Bestand. Eine reservierte Maschine hat Platz, aber // nicht für den, der hier fragt. ->unreserved() ->when($datacenter !== null, fn ($q) => $q->where('datacenter', $datacenter)) ->get() ->map(fn (Host $host) => $host->availableGb()) ->max() ?? 0; } ``` Ebenso die Abfrage `$hasHost` in `unplaceablePlans()` um `->unreserved()` ergänzen. In `app/Provisioning/Steps/Customer/ReserveResources.php`: ```php : Host::placeableIn($order->datacenter, (int) $plan['disk_gb'], $order->customer_id); ``` - [ ] **Step 5: In der Konsole setzen und lösen** `app/Livewire/Admin/HostDetail.php` um zwei Methoden erweitern (`reserveFor(string $customerUuid)`, `releaseReservation()`), beide mit `$this->authorize('hosts.manage')` (die vorhandene Fähigkeit für diese Seite prüfen und dieselbe verwenden). Die Bestätigung fürs Lösen gehört nach R23 in ein Modal — dem Muster von `ConfirmRemoveHost` folgen. Auf der Host-Liste und der Kapazitätsseite die Reservierung als Zeile ausweisen, damit die Maschine nicht verschwindet, sondern erklärt dasteht. - [ ] **Step 6: Tests und Commit** ```bash docker compose exec -T -e HOME=/tmp -u www-data app php artisan test git add database/migrations app/Models/Host.php app/Services/Provisioning/HostCapacity.php app/Provisioning/Steps/Customer/ReserveResources.php app/Livewire/Admin resources/views lang tests/Feature/Admin/HostReservationTest.php git commit -m "Ein reservierter Host gehoert seinem Kunden placeableIn() nahm jeden aktiven Host im Rechenzentrum, und hasRoomFor() sowie largestPlaceableGb() zaehlten eine exklusiv verkaufte Maschine obendrein mit — der Shop versprach damit Platz, der bereits vergeben war. Ohne diese Markierung ist 'eigener Server' ein Satz im Angebot und nirgends eine Tatsache. Co-Authored-By: Claude Opus 5 " ``` --- ### Task 8: Enterprise als Anfrage auf dem Preisblatt **Files:** - Modify: `app/Http/Controllers/LandingController.php` (Anfrage-Block) - Modify: `resources/views/landing.blade.php` - Modify: `lang/de/landing.php`, `lang/en/landing.php` - Test: `tests/Feature/LandingPriceSheetTest.php` (ergänzen) - [ ] **Step 1: Den fehlschlagenden Test schreiben** In `tests/Feature/LandingPriceSheetTest.php` ergänzen: ```php it('zeigt Enterprise als Anfrage statt mit Preis', function () { App\Models\PlanFamily::query()->where('key', 'enterprise')->update(['sales_enabled' => false]); $response = $this->get('/'); $response->assertOk() ->assertSee(__('landing.enterprise_title')) ->assertDontSee('799'); }); ``` - [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen** Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/LandingPriceSheetTest.php` Erwartet: FEHLSCHLAG — der Block fehlt. - [ ] **Step 3: Den Block bauen** Im `LandingController` einen festen Abschnitt neben der Vergleichstabelle zurückgeben (kein Katalogeintrag, denn es gibt keinen Preis): ```php // Enterprise steht nicht in der Tabelle, weil es keinen Preis hat, den // man vergleichen könnte: ab 500 GB ist es eine eigene Maschine, und // die wird angeboten, nicht ausgepreist. Im Shop stünde es außerdem // schief — placeableIn() fände auf 388 GB vergebbarem Platz keinen // Host, und der Kunde erführe das nach der Zahlung. 'enterprise' => [ 'title' => __('landing.enterprise_title'), 'body' => __('landing.enterprise_body'), 'cta' => __('landing.enterprise_cta'), 'href' => route('contact'), ], ``` Routenname gegen `routes/web.php` prüfen; gibt es keine Kontaktseite, führt der Knopf auf `mailto:` mit der Adresse aus der vorhandenen Konfiguration. Texte (`lang/de/landing.php`): ```php 'enterprise_title' => 'Mehr als 175 GB? Dann eine eigene Maschine.', 'enterprise_body' => 'Ab 500 GB richten wir einen Server ein, auf dem nur Ihre Cloud läuft — Größe, Leistung und Sicherung nach Ihrem Bedarf. Wir rechnen Ihnen das durch.', 'enterprise_cta' => 'Angebot anfragen', ``` - [ ] **Step 4: Tests und Commit** ```bash docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/LandingPriceSheetTest.php git add app/Http/Controllers/LandingController.php resources/views/landing.blade.php lang tests/Feature/LandingPriceSheetTest.php git commit -m "Enterprise wird angefragt, nicht ausgepreist Ab 500 GB ist es eine eigene Maschine — im Shop faende placeableIn() auf 388 GB vergebbarem Platz keinen Host, und der Kunde erfuehre das nach der Zahlung. Co-Authored-By: Claude Opus 5 " ``` --- ### Task 9: Die Umschaltung — Migration und die Suite dahinter **Warum eine Migration und kein Befehl:** Der Katalog wird von `2026_07_26_040000_create_plan_catalogue_tables.php` gesetzt, mit fest eingetragenen Werten und der Begründung, sie müsse „in fünf Jahren dieselben Werte setzen, was auch immer config dann sagt". Ein Befehl allein ließe jede Neuinstallation und die gesamte Testsuite auf der alten Leiter zurück. **Das ist die größte Aufgabe des Plans.** Nach der Migration sieht jeder Test die neue Leiter; rund 28 Testdateien nennen heute alte Zahlen. Sie gehören zur Aufgabe: eine Migration ohne grüne Suite ist nicht fertig. **Files:** - Create: `database/migrations/2026_08_01_000004_switch_to_new_plan_ladder.php` - Modify: rund 28 Testdateien (siehe Schritt 5) - Test: `tests/Feature/Billing/NewPlanLadderTest.php` - [ ] **Step 1: Den fehlschlagenden Test schreiben** `tests/Feature/Billing/NewPlanLadderTest.php`: ```php sellable()))->toBe(['start', 'team', 'business']); }); it('verkauft sie zu den beschlossenen Zahlen', function () { $sellable = app(PlanCatalogue::class)->sellable(); expect($sellable['start']['quota_gb'])->toBe(30) ->and($sellable['start']['disk_gb'])->toBe(40) ->and($sellable['start']['ram_mb'])->toBe(6144) ->and($sellable['start']['seats'])->toBe(10) ->and($sellable['start']['price_cents'])->toBe(3900) ->and($sellable['start']['yearly_price_cents'])->toBe(39000) ->and($sellable['team']['quota_gb'])->toBe(85) ->and($sellable['team']['disk_gb'])->toBe(100) ->and($sellable['team']['price_cents'])->toBe(7900) ->and($sellable['business']['quota_gb'])->toBe(175) ->and($sellable['business']['disk_gb'])->toBe(200) ->and($sellable['business']['price_cents'])->toBe(13900); }); it('lässt neun Startkunden auf 388 GB', function () { $start = app(PlanCatalogue::class)->sellable()['start']; expect(intdiv(388, $start['disk_gb']))->toBe(9); }); it('nimmt das interne Paket und Enterprise aus dem Laden', function () { expect(PlanFamily::query()->where('key', 'test')->value('internal'))->toBeTruthy() ->and(PlanFamily::query()->where('key', 'test')->value('name'))->toBe('Intern') ->and(PlanFamily::query()->where('key', 'enterprise')->value('sales_enabled'))->toBeFalsy(); }); it('lässt einen laufenden Vertrag auf seinen eigenen Zahlen', function () { // Ein Vertrag, wie ihn OpenSubscription vor der Umschaltung geschrieben // hat, mit der Maschine dazu. $subscription = Subscription::factory()->create([ 'plan' => 'team', 'quota_gb' => 500, 'disk_gb' => 540, 'price_cents' => 17900, ]); $instance = App\Models\Instance::factory()->create([ 'customer_id' => $subscription->customer_id, 'status' => 'active', 'quota_gb' => 500, 'disk_gb' => 540, ]); // Der Katalog sagt heute etwas anderes — und keine Leseseite schreibt es // dem Vertrag um. Die mittlere Zusicherung ist die eigentliche: sie geht // durch denselben Weg, über den eine Bereitstellung das Kontingent holt. expect(app(PlanCatalogue::class)->sellable()['team']['quota_gb'])->toBe(85) ->and(App\Services\Billing\StorageAllowance::for($instance)->planGb)->toBe(500) ->and($subscription->fresh()->price_cents)->toBe(17900); }); ``` - [ ] **Step 2: Test laufen lassen und Fehlschlag prüfen** Aufruf: `docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Billing/NewPlanLadderTest.php` Erwartet: FEHLSCHLAG — verkauft werden vier Pakete zu den alten Zahlen. - [ ] **Step 3: Die Migration schreiben** `database/migrations/2026_08_01_000004_switch_to_new_plan_ladder.php`: ```php > */ private const LADDER = [ 'test' => ['quota_gb' => 5, 'disk_gb' => 20, 'ram_mb' => 4096, 'cores' => 2, 'seats' => 3, 'traffic_gb' => 1000, 'performance' => 'standard', 'price_cents' => 100, 'free_months' => 2, 'features' => '["managed_updates","daily_backups","monitoring","subdomain","custom_domain"]'], 'start' => ['quota_gb' => 30, 'disk_gb' => 40, 'ram_mb' => 6144, 'cores' => 2, 'seats' => 10, 'traffic_gb' => 1000, 'performance' => 'standard', 'price_cents' => 3900, 'free_months' => 2, 'features' => '["managed_updates","daily_backups","monitoring","subdomain"]'], 'team' => ['quota_gb' => 85, 'disk_gb' => 100, 'ram_mb' => 8192, 'cores' => 4, 'seats' => 25, 'traffic_gb' => 2000, 'performance' => 'enhanced', 'price_cents' => 7900, 'free_months' => 2, 'features' => '["managed_updates","daily_backups","monitoring","subdomain","office","branding","priority_support"]'], 'business' => ['quota_gb' => 175, 'disk_gb' => 200, 'ram_mb' => 12288, 'cores' => 6, 'seats' => 50, 'traffic_gb' => 4000, 'performance' => 'high', 'price_cents' => 13900, 'free_months' => 2, 'features' => '["managed_updates","daily_backups","monitoring","custom_domain","office","branding","priority_support","extended_retention","audit_log"]'], ]; public function up(): void { $now = now(); DB::transaction(function () use ($now) { foreach (self::LADDER as $key => $spec) { $family = DB::table('plan_families')->where('key', $key)->first(); // Eine Familie, die es nicht gibt, wird nicht erfunden: dann ist // dieser Bestand anders aufgebaut, und das gehört gesehen. if ($family === null) { continue; } $current = DB::table('plan_versions') ->where('plan_family_id', $family->id) ->whereNotNull('published_at') ->where('available_from', '<=', $now) ->where(fn ($q) => $q->whereNull('available_until')->orWhere('available_until', '>', $now)) ->orderByDesc('version') ->first(); // Schon umgeschaltet (die laufende Version trägt die neuen // Zahlen): nichts zu tun, und wiederholbar bleibt es damit auch. if ($current !== null && (int) $current->quota_gb === (int) $spec['quota_gb'] && (int) $current->disk_gb === (int) $spec['disk_gb']) { continue; } if ($current !== null) { DB::table('plan_versions')->where('id', $current->id) ->update(['available_until' => $now, 'updated_at' => $now]); } $next = (int) DB::table('plan_versions')->where('plan_family_id', $family->id)->max('version') + 1; $versionId = DB::table('plan_versions')->insertGetId([ 'uuid' => (string) Str::uuid(), 'plan_family_id' => $family->id, 'version' => $next, 'quota_gb' => $spec['quota_gb'], 'traffic_gb' => $spec['traffic_gb'], 'seats' => $spec['seats'], 'free_months' => $spec['free_months'], 'ram_mb' => $spec['ram_mb'], 'cores' => $spec['cores'], 'disk_gb' => $spec['disk_gb'], 'performance' => $spec['performance'], 'template_vmid' => $current->template_vmid ?? 9000, 'features' => $spec['features'], 'available_from' => $now, 'available_until' => null, 'published_at' => $now, 'created_at' => $now, 'updated_at' => $now, ]); // Der Jahrespreis wird gerechnet, nicht getippt: zwölf Monate // minus die freien, zum Monatspreis. Zwei Zahlen für dieselbe // Sache driften auseinander, und die Zeile "zwei Monate frei" // muss stimmen, was auch immer Stripe abbucht. $yearly = (int) $spec['price_cents'] * (12 - (int) $spec['free_months']); foreach ([['monthly', (int) $spec['price_cents']], ['yearly', $yearly]] as [$term, $amount]) { DB::table('plan_prices')->insert([ 'uuid' => (string) Str::uuid(), 'plan_version_id' => $versionId, 'term' => $term, 'amount_cents' => $amount, 'currency' => 'EUR', 'stripe_price_id' => null, 'created_at' => $now, 'updated_at' => $now, ]); } } // Das Testpaket heißt Intern und verlässt den Laden, bleibt aber // verschenkbar. DB::table('plan_families')->where('key', 'test')->update([ 'name' => 'Intern', 'internal' => true, 'audience' => 'Nur für interne Tests', 'note' => 'Nicht im Verkauf. Wird von Hand vergeben.', 'updated_at' => $now, ]); // Enterprise wird angefragt, nicht gekauft: 500 GB finden auf 388 GB // vergebbarem Platz keinen Host, und der Kunde erführe das nach der // Zahlung. DB::table('plan_families')->where('key', 'enterprise')->update([ 'sales_enabled' => false, 'updated_at' => $now, ]); }); } /** * Kein Rückweg. * * Eine veröffentlichte Version zu löschen hieße, Verträge zu löschen, die * darauf verweisen. Wer zurück will, schaltet die alte Version über die * Konsole wieder in den Verkauf. */ public function down(): void {} }; ``` `use Illuminate\Support\Str;` am Dateikopf ergänzen. Spaltennamen von `plan_versions` und `plan_prices` gegen `2026_07_26_040000_create_plan_catalogue_tables.php` prüfen — die Migration muss die echten treffen, nicht die hier vermuteten. - [ ] **Step 4: Migration fahren und den neuen Test grün sehen** ```bash docker compose exec -T -e HOME=/tmp -u www-data app php artisan migrate docker compose exec -T -e HOME=/tmp -u www-data app php artisan test tests/Feature/Billing/NewPlanLadderTest.php ``` Erwartet: BESTANDEN. - [ ] **Step 5: Die Suite nachziehen** ```bash docker compose exec -T -e HOME=/tmp -u www-data app php artisan test ``` Jeder Fehlschlag ist eine von drei Sorten, und die Unterscheidung ist die ganze Arbeit: 1. **Der Test nennt eine alte Zahl, meint aber „was das Paket kostet".** Zahl austauschen (17900 → 7900, 500 → 85, 540 → 100 …). 2. **Der Test nennt eine alte Zahl, weil er einen Betrag prüft, der aus ihr folgt** (Rechnungssummen, Umsatzberichte, anteilige Beträge). Neu ausrechnen, nicht raten — und wenn die Rechnung im Test steht, sie stehen lassen und nur die Eingangsgrößen ändern. 3. **Der Test bricht, weil es `test` oder `enterprise` im Verkauf nicht mehr gibt.** Das ist ein echter Fund: entweder auf ein verkäufliches Paket umstellen oder — wenn der Test gerade das Verschenken prüft — auf `currentVersion()` statt `sellable()`. Nach jeder Gruppe die Suite erneut fahren. Am Ende: **alle Tests grün, keine übersprungen.** - [ ] **Step 6: Commit** ```bash git add database/migrations tests git commit -m "Die neue Leiter: Start 39, Team 79, Business 139 Zugeschnitten auf die Maschine, die dasteht. 388 GB vergebbar geteilt durch 40 GB Platte sind neun Startkunden statt drei — Speicher und RAM gehen damit gleichzeitig aus, wo vorher sechs RAM-Plaetze verfielen. 351 Euro Umsatz je Server statt 147. Umgeschaltet wird durch Beenden und Nachfolgen, nicht durch Aendern: eine veroeffentlichte Version ist unveraenderlich, und laufende Vertraege tragen ihren eigenen Schnappschuss. Als Migration und nicht als Befehl, weil der Katalog von einer Migration gesetzt wird — sonst blieben Neuinstallation und Testsuite auf einer Leiter, die wir nicht mehr verkaufen. Co-Authored-By: Claude Opus 5 " ``` --- ### Task 10: Stripe, Prüfung, Auslieferung **Files:** - Keine Codeänderung erwartet. Ändert sich doch etwas, gehört es in einen eigenen Commit mit eigener Begründung. - [ ] **Step 1: Katalog prüfen** ```bash docker compose exec -T -e HOME=/tmp -u www-data app php artisan plans:check ``` Erwartet: keine Überschneidungen, keine Lücken, keine fehlenden Preise. - [ ] **Step 2: Stripe im Trockenlauf** ```bash docker compose exec -T -e HOME=/tmp -u www-data app php artisan stripe:sync-catalogue --dry-run ``` Erwartet: eine Liste der Objekte, die entstünden — acht Preise (vier Versionen × zwei Laufzeiten). **Nicht gegen das Livekonto laufen lassen.** Stripe-Preise lassen sich nicht löschen; der echte Lauf ist die Entscheidung des Betreibers. Verweigert der Befehl den Dienst, weil der gespeicherte Katalog zu einem anderen Konto gehört, ist das eine Warnung vor dem Leeren eines Katalogs, an dem laufende Verträge hängen — die Meldung wörtlich weitergeben. - [ ] **Step 3: Ganze Suite** ```bash docker compose exec -T -e HOME=/tmp -u www-data app php artisan test ``` Erwartet: alles grün. - [ ] **Step 4: Codex-Durchsicht (R15)** Die vorgeschriebene Durchsicht fahren (siehe `docs/` und `clupilot-r15-codex-review`), Befunde einarbeiten oder begründet verwerfen. - [ ] **Step 5: Version und Auslieferung** `VERSION` erhöhen, Release-Commit und `v*`-Tag nach dem üblichen Verfahren — Aktualisierungen sind auf Tags angewiesen, ein Commit ohne Tag erreicht keinen Server. **Nicht pushen**, das macht der Betreiber. - [ ] **Step 6: Dem Betreiber sagen, was er selbst tun muss** 1. `stripe:sync-catalogue` gegen das Livekonto laufen lassen (seine Entscheidung, nicht die des Umsetzenden). 2. Auf dem Livesystem prüfen, ob Blöcke gebucht sind: ```bash docker compose exec -T -u www-data -e XDG_CONFIG_HOME=/tmp app php artisan tinker --execute="\$p=App\Models\SubscriptionAddon::where('addon_key','storage')->whereNull('cancelled_at')->get(); echo 'Bloecke: '.\$p->sum('quantity').' auf '.\$p->count().' Buchungen'.PHP_EOL;" ``` Ist die Zahl größer als 0, muss **vor** dem Deployment sichtbar sein, dass `subscription_addons.pack_gb` auf 100 nachgetragen wurde. 3. Alte Stripe-Preise erst dann aufräumen (`stripe:sweep-orphan-prices`), wenn kein Vertrag mehr darauf läuft. --- ## Selbstprüfung des Plans **Abdeckung des Entwurfs:** Kopfraumregel → Task 9 (Zahlen) und Task 4 (gestapelt). Blockpreis und Deckel → Tasks 2 und 5. Drei Reparaturen → Tasks 1, 2, 3. Kopfraum der Blöcke → Task 4. Internes Paket → Task 6. Enterprise und Reservierung → Tasks 7 und 8. Jahrespreis über `free_months` → Task 9 (`free_months = 2`, Jahresbetrag gerechnet). Umschaltung und Bestand → Task 9. Stripe → Task 10. **Abweichung vom Entwurf, bewusst:** Der Entwurf sah einen Artisan-Befehl `plans:switch-catalogue` vor. Der Plan macht daraus eine Migration, weil der Katalog von einer Migration gesetzt wird — ein Befehl ließe Neuinstallationen und die Testsuite auf der alten Leiter. Der Entwurf ist entsprechend nachzuziehen, wenn der Betreiber zustimmt. **Vermutete Namen, die der Umsetzende prüfen muss** (jeweils im Schritt vermerkt): Spalten der Host-Factory und `Host::availableGb()` (Task 3, 7), die Verknüpfung Vertrag → Instanz (Task 3), Routen- und Feldnamen des Checkouts (Task 6), Spaltennamen von `plan_versions`/`plan_prices` (Task 9), Name der Kontaktroute (Task 8).