From 1e570f17fc1ea1d09fc89c6208bd1f8c4df86902 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 12:06:53 +0200 Subject: [PATCH] Plan: die neuen Pakete in zehn Aufgaben MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Die Reihenfolge ist der Plan: erst die drei Reparaturen am Zusatzspeicher, dann die Umschaltung. Wer die Konfiguration zuerst umstellt, verkleinert gekaufte Bloecke. Eine Abweichung vom Entwurf, mit Grund: die Umschaltung wird eine Migration und kein Befehl. Der Katalog wird von einer Migration gesetzt, mit fest eingetragenen Werten und der Begruendung, sie muesse in fuenf Jahren dieselben Werte setzen — ein Befehl allein liesse jede Neuinstallation und die gesamte Testsuite auf der alten Leiter zurueck. Die 28 Testdateien mit alten Zahlen gehoeren deshalb in dieselbe Aufgabe. Co-Authored-By: Claude Opus 5 --- .../plans/2026-08-01-neue-pakete.md | 1874 +++++++++++++++++ 1 file changed, 1874 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-01-neue-pakete.md diff --git a/docs/superpowers/plans/2026-08-01-neue-pakete.md b/docs/superpowers/plans/2026-08-01-neue-pakete.md new file mode 100644 index 0000000..97eefda --- /dev/null +++ b/docs/superpowers/plans/2026-08-01-neue-pakete.md @@ -0,0 +1,1874 @@ +# 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 () { + $blockPerGb = 1500 / 20; // 75 ct + $startZuTeam = (7900 - 3900) / (85 - 30); // ~72,7 ct + $teamZuBusiness = (13900 - 7900) / (175 - 85); // ~66,7 ct + + expect($blockPerGb)->toBeGreaterThan($startZuTeam) + ->and($blockPerGb)->toBeGreaterThan($teamZuBusiness); +}); + +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 unberührt', function () { + // Ein Vertrag, der vor der Umschaltung auf altem Team geschlossen wurde, + // trägt seine eigenen Zahlen und liest den Katalog nicht mehr. + $subscription = Subscription::factory()->create([ + 'plan' => 'team', 'quota_gb' => 500, 'disk_gb' => 540, 'price_cents' => 17900, + ]); + + expect($subscription->fresh()->quota_gb)->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).