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

73 KiB
Raw Blame History

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

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

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():

    /**
     * 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:

    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,:

                // 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:

    /** 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:

            '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
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

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:

    // 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():

    /** 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:

        // 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:

    /**
     * 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():

            $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():

    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:

    '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:

    '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
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

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:

        // 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:

    '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:

    'storage_no_room' => 'Your server currently has no room for more storage. Please contact support — we will make room.',
  • Step 5: Tests und Commit
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

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():

        $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
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

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:

    /*
    | 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:

    /**
     * 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:

        $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:

    'addon_not_available' => ':module ist für Ihr Paket nicht vorgesehen.',

lang/en/billing.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
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

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

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():

        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']);:

        // 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
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): ?selfdie 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

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

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:

    /**
     * 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:

    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:

                : 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
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:

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):

        // 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):

    '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
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

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

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

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
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
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:
    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).