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