73 KiB
Die neuen Pakete — Umsetzungsplan
For agentic workers: REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (
- [ ]) syntax for tracking.
Ziel: Die Paketleiter auf die Maschine zuschneiden, die dasteht — neun Kunden je Server statt drei —, den Zusatzspeicher so bepreisen, dass Aufsteigen billiger bleibt als Stapeln, das Testpaket aus dem Verkauf nehmen und Enterprise auf Anfrage stellen.
Architektur: Erst die drei Reparaturen am Zusatzspeicher, dann die Umschaltung des Katalogs. Diese Reihenfolge ist keine Stilfrage: die Packungsgröße wird heute zur Anzeigezeit aus der Konfiguration gelesen, also schrumpft ein gekaufter 100-GB-Block in der Sekunde auf 20 GB, in der die Konfiguration umgestellt wird. Die Umschaltung selbst ist eine Migration mit fest eingetragenen Werten, im Stil der Migration, die den Katalog ursprünglich gesetzt hat — ein Befehl allein ließe jede Neuinstallation und die gesamte Testsuite auf einer Leiter zurück, die wir nicht mehr verkaufen.
Tech-Stack: Laravel 13.8, Livewire 3 (klassenbasiert, kein Volt), Pest, MariaDB 11.4, Tailwind v4, Stripe.
Entwurf: docs/superpowers/specs/2026-08-01-neue-pakete-design.md
Global Constraints
- Tests laufen so:
docker compose exec -T -e HOME=/tmp -u www-data app php artisan test(einzelne Datei:… php artisan test tests/Feature/…Test.php). - Die Reihenfolge ist bindend: Aufgaben 1–5 vor Aufgabe 9. Wer die Konfiguration vor dem Einfrieren umstellt, verkleinert gekaufte Blöcke.
- Geld ist netto und in Cent, umgerechnet ausschließlich über
App\Support\Money. - Regeln aus
rules.mdgelten 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(KonstanteFROZEN,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:
StorageAllowanceauf 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, Vorgabefalse). -
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): ?self— die Signatur ändert sich; alle Aufrufer sindHostCapacity::canPlace()undReserveResources. -
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:
- Der Test nennt eine alte Zahl, meint aber „was das Paket kostet". Zahl austauschen (17900 → 7900, 500 → 85, 540 → 100 …).
- 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.
- Der Test bricht, weil es
testoderenterpriseim 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 — aufcurrentVersion()stattsellable().
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
stripe:sync-cataloguegegen das Livekonto laufen lassen (seine Entscheidung, nicht die des Umsetzenden).- Auf dem Livesystem prüfen, ob Blöcke gebucht sind:
Ist die Zahl größer als 0, muss vor dem Deployment sichtbar sein, dassdocker 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;"subscription_addons.pack_gbauf 100 nachgetragen wurde. - 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).