CluPilotCloud/docs/superpowers/plans/2026-08-01-neue-pakete.md

1892 lines
73 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters!

This file contains ambiguous Unicode characters that may be confused with others in your current locale. If your use case is intentional and legitimate, you can safely ignore this warning. Use the Escape button to highlight these characters.

# 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 15 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 <noreply@anthropic.com>`.
- **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
<?php
use App\Actions\BookAddon;
use App\Actions\OpenSubscription;
use App\Models\Order;
use App\Models\Subscription;
use App\Models\SubscriptionAddon;
use App\Services\Billing\AddonCatalogue;
use App\Services\Billing\StorageAllowance;
/**
* Wie groß ein gekaufter Block ist.
*
* Die Größe stand in der Konfiguration und wurde bei jeder Anzeige neu gelesen.
* Solange sie sich nie bewegte, fiel das nicht auf; sie bewegt sich jetzt von
* 100 GB auf 20 GB. Ein Block, dessen Größe erst zur Anzeigezeit entsteht, ist
* kein gekauftes Gut, sondern eine Ansicht.
*/
function packContract(string $plan = 'team'): Subscription
{
$order = Order::factory()->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
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Schema;
/**
* Wie groß ein gekaufter Block ist — auf der Buchung, nicht in der Konfiguration.
*
* `subscription_addons` friert seit jeher ein, was der Kunde zu ZAHLEN
* zugesagt hat. Was er dafür BEKOMMT, stand weiter in der Konfiguration und
* wurde bei jeder Anzeige neu gelesen. Solange es eine einzige Packungsgröße
* gab, war das folgenlos; mit dem Zuschnitt von 100 GB auf 20 GB wäre es der
* stille Verlust von 80 GB je gekauftem Block.
*
* Die 100 steht hier als Zahl und nicht als config()-Aufruf. Was verkauft
* wurde, ist Geschichte: eine Migration, die den Nachtrag aus der
* Konfiguration zöge, schriebe je nach Reihenfolge des Deployments 20 hinein
* und wäre damit genau der Fehler, den sie behebt.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('subscription_addons', function (Blueprint $table) {
// 0 für alles, was kein Speicher ist: ein Support-Tarif hat keine
// Größe, und NULL wäre eine dritte Antwort auf eine Frage mit zwei.
$table->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 <noreply@anthropic.com>"
```
---
### 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
<?php
use App\Actions\BookAddon;
use App\Actions\OpenSubscription;
use App\Models\Order;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
/**
* Höchstens drei Blöcke.
*
* Der Deckel liegt dort, wo Aufsteigen billiger wird: Start + 3 Blöcke sind
* 90 GB für 84 €, Team ist 85 GB für 79 € mit mehr Nutzern und mehr RAM. Er
* gehört in die Aktion und nicht ins Formular — dieselbe Lektion wie bei
* ReissueTakeover (v1.3.82): die Ansicht versteckte den Knopf, die Methode
* prüfte nichts, und Codex fand es zweimal.
*/
function limitContract(string $plan = 'start'): Subscription
{
$order = Order::factory()->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 <noreply@anthropic.com>"
```
---
### 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
<?php
use App\Actions\BookAddon;
use App\Models\Host;
use App\Models\Instance;
use App\Models\Order;
use App\Models\Subscription;
use App\Services\Billing\AddonCatalogue;
/**
* Ein Block ist Platz auf einer bestimmten Maschine.
*
* Eine laufende Instanz zieht nicht um — es gibt keinen Umzug zwischen Hosts.
* Die Frage ist deshalb nicht "hat irgendein Host Platz", sondern "hat DIESER
* Host Platz", und sie muss gestellt werden, bevor Geld fließt.
*/
function packedInstanceOn(Host $host): Subscription
{
$order = Order::factory()->create(['plan' => 'start', 'datacenter' => $host->datacenter, 'status' => 'paid']);
$subscription = app(App\Actions\OpenSubscription::class)($order);
$instance = Instance::factory()->create([
'customer_id' => $subscription->customer_id,
'host_id' => $host->id,
'status' => 'active',
'quota_gb' => 30,
'disk_gb' => 40,
]);
$subscription->update(['instance_id' => $instance->id]);
return $subscription->refresh();
}
it('lehnt einen Block ab, für den der Host keinen Platz hat', function () {
config(['provisioning.storage_addon.gb' => 20, 'provisioning.storage_addon.disk_gb' => 22]);
// 60 GB gesamt, davon 40 durch die Instanz gebunden: für 22 weitere ist
// kein Platz. `active()` setzt total_gb/RAM/Kerne — ohne diesen Zustand
// hat ein Host gar keine Größe.
$host = Host::factory()->active()->create(['datacenter' => 'fsn', 'total_gb' => 60]);
$subscription = packedInstanceOn($host);
expect(fn () => app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1))
->toThrow(RuntimeException::class);
expect($subscription->addons()->count())->toBe(0);
});
it('lässt einen Block durch, für den der Host Platz hat', function () {
config(['provisioning.storage_addon.gb' => 20, 'provisioning.storage_addon.disk_gb' => 22]);
$host = Host::factory()->active()->create(['datacenter' => 'fsn', 'total_gb' => 500]);
$subscription = packedInstanceOn($host);
app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1);
expect((int) $subscription->addons()->active()->sum('quantity'))->toBe(1);
});
it('fragt nicht, solange es keine Maschine gibt', function () {
config(['provisioning.storage_addon.gb' => 20, 'provisioning.storage_addon.disk_gb' => 22]);
$order = Order::factory()->create(['plan' => 'start', 'datacenter' => 'fsn', 'status' => 'paid']);
$subscription = app(App\Actions\OpenSubscription::class)($order);
app(BookAddon::class)($subscription, AddonCatalogue::STORAGE, 1);
expect((int) $subscription->addons()->active()->sum('quantity'))->toBe(1);
});
```
**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 <noreply@anthropic.com>"
```
---
### 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
<?php
use App\Services\Billing\StorageAllowance;
/**
* Was ein Block auf der Platte bedeutet.
*
* Der Kopfraum wächst mit der Instanz (max(10 GB, 12 %)), sonst gilt die Regel
* nur für frisch gekaufte Pakete und nicht für gestapelte. Eine volle Platte
* legt nicht den Upload still, sondern die Instanz.
*/
it('rechnet Kopfraum des Pakets und der Blöcke zusammen', function () {
// Start: 30 GB Kontingent auf 40 GB Platte. Drei Blöcke zu 20 GB nutzbar
// und 22 GB belegt.
$allowance = new StorageAllowance(planGb: 30, packs: 3, bookedGb: 60, bookedDiskGb: 66, packSizeGb: 20);
$planDisk = 40;
$planQuota = 30;
$target = $allowance->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 <noreply@anthropic.com>"
```
---
### 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
<?php
use App\Actions\BookAddon;
use App\Actions\OpenSubscription;
use App\Models\Order;
use App\Services\Billing\AddonCatalogue;
/**
* Was ein Block kostet, und wer ihn nicht kaufen kann.
*
* Die Preisregel: ein Block muss teurer je GB sein als der Aufstieg in die
* nächste Stufe. 20 GB für 15 € sind 0,75 €/GB gegen 0,73 (Start → Team) und
* 0,67 (Team → Business). Bei 12 € wäre er mit 0,60 billiger als jeder
* Aufstieg gewesen, und Stapeln hätte sich gelohnt.
*/
it('verkauft 20 GB für 15 Euro netto', function () {
$catalogue = app(AddonCatalogue::class);
expect($catalogue->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 <noreply@anthropic.com>"
```
---
### 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
<?php
use App\Models\PlanFamily;
use App\Services\Billing\PlanCatalogue;
/**
* Ein Paket, das nur der Betrieb sieht.
*
* Das Testpaket ist für Abnahmeläufe da. Es aus dem Verkauf zu nehmen, indem
* man `sales_enabled` abschaltet, nähme es auch aus dem Verschenken —
* currentVersion() wirft dann —, und genau dafür existiert es.
*/
beforeEach(function () {
PlanFamily::query()->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
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Pakete, die es gibt, aber nicht zu kaufen.
*
* `sales_enabled` ist dafür der falsche Hebel: es schaltet auch das Verschenken
* ab, weil currentVersion() bei einer nicht verkäuflichen Familie wirft. Ein
* internes Paket soll aus dem Laden verschwinden und in der Konsole bleiben —
* das sind zwei Fragen, und sie brauchen zwei Felder.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('plan_families', function (Blueprint $table) {
$table->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 <noreply@anthropic.com>"
```
---
### 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
<?php
use App\Models\Customer;
use App\Models\Host;
use App\Services\Provisioning\HostCapacity;
/**
* Ein Host, der jemandem gehört.
*
* Enterprise verkauft eine eigene Maschine. Ohne Markierung landet der nächste
* Startkunde darauf, und die Kapazitätszahlen versprechen den Platz obendrein
* ein zweites Mal.
*/
it('platziert keinen fremden Kunden auf einem reservierten Host', function () {
$mieter = Customer::factory()->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
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;
/**
* Wem eine Maschine gehört.
*
* "Eigener Server" war bisher ein Satz im Angebot und nirgends eine Tatsache:
* die Platzierung nimmt den ersten aktiven Host im Rechenzentrum, und die
* Kapazitätszahlen zählen ihn mit. Beides zusammen verkauft denselben Platz
* zweimal.
*
* Null heißt allgemeiner Bestand. Die Beziehung löscht nicht mit dem Kunden:
* ein Host überlebt seinen Mieter, und die Reservierung wird beim Auszug von
* Hand gelöst — sonst stünde die Maschine still im Bestand, ohne dass jemand
* sie angeboten hätte.
*/
return new class extends Migration
{
public function up(): void
{
Schema::table('hosts', function (Blueprint $table) {
$table->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 <noreply@anthropic.com>"
```
---
### 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 <noreply@anthropic.com>"
```
---
### 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
<?php
use App\Models\PlanFamily;
use App\Models\Subscription;
use App\Services\Billing\PlanCatalogue;
/**
* Die Leiter, wie sie verkauft wird.
*
* Zugeschnitten auf die Maschine, die dasteht: 388 GB vergebbar, ~58 GB RAM für
* Gäste. Neun Startkunden à 40 GB Platte lasten Speicher und RAM gleichzeitig
* aus — die alte Leiter band bei drei Kunden und ließ sechs RAM-Plätze
* verfallen.
*/
it('verkauft drei Pakete', function () {
expect(array_keys(app(PlanCatalogue::class)->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
<?php
use Illuminate\Database\Migrations\Migration;
use Illuminate\Support\Facades\DB;
/**
* Die neue Leiter.
*
* Die Werte stehen hier als Zahlen, wie schon in der Migration, die den Katalog
* gesetzt hat: eine Umschaltung, die ihre Zahlen aus der Konfiguration zöge,
* setzte in fünf Jahren etwas anderes als heute.
*
* Eine veröffentlichte Version ist unveränderlich. Umgeschaltet wird deshalb
* nicht durch Ändern, sondern durch Beenden und Nachfolgen: die laufende
* Version bekommt ein `available_until`, die neue ein `available_from` auf
* dieselbe Sekunde. Das Fenster ist halboffen, also überschneidet sich nichts
* und es gibt keinen Job, der laufen müsste.
*
* Laufende Verträge merken davon nichts — sie tragen ihren eigenen
* eingefrorenen Schnappschuss, und seit dem Katalogumbau liest kein Vertrag
* mehr den Katalog.
*/
return new class extends Migration
{
/** @var array<string, array<string, int|string>> */
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 <noreply@anthropic.com>"
```
---
### 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).