# Die neuen Pakete

Entwurf vom 1. August 2026, beschlossen mit dem Betreiber. Ersetzt die Leiter,
die seit dem Katalogumbau vom 26. Juli im Verkauf steht.

## Warum

Die alte Leiter (Start 100 GB / 49 €, Team 500 GB / 179 €, Business 1 TB / 399 €,
Enterprise 2 TB / 799 €) wurde entworfen, bevor jemand ausgerechnet hatte, was
eine Maschine trägt. **Speicher bindet, nicht Rechenleistung.** Ein Host mit
456 GB und 15 % Reserve vergibt 388 GB — auf der alten Leiter sind das **drei
Kunden je Server** (147 € Umsatz gegen 60–80 € Miete), während RAM (64 GB) und
CPU (12 Kerne) acht bis sechzehn getragen hätten. Sechs RAM-Plätze verfielen.

Die neue Leiter ist auf die vorhandene Maschine zugeschnitten: das Optimum liegt
dort, wo Speicher und RAM **gleichzeitig** ausgehen, bei rund 6 GB RAM je
Nextcloud also bei neun Kunden à 40 GB Platte. Umsatz je Server: **351 €**.

Der größere Hebel bleibt die Hardware — dieselbe Miete mit 2× 4 TB statt
2× 512 GB macht aus drei Kunden rund dreißig. Das ist eine Beschaffungs­frage und
gehört nicht in diesen Entwurf.

## Die Leiter

Gebucht wird gegen den Host die **Platte**, nicht das Kontingent. Deshalb ist die
runde Zahl die Platte, und der Kunde sieht, was davon ihm gehört.

| Paket | Kunde sieht | Platte | Kopfraum | RAM | Kerne | Nutzer | Traffic | Klasse | netto/Monat | Jahr (2 frei) |
|---|---|---|---|---|---|---|---|---|---|---|
| **Intern** | 5 GB | 20 GB | 15 GB | 4 GB | 2 | 3 | 1 TB | standard | 1 € | 10 € |
| **Start** | 30 GB | 40 GB | 10 GB | 6 GB | 2 | 10 | 1 TB | standard | 39 € | 390 € |
| **Team** | 85 GB | 100 GB | 15 GB | 8 GB | 4 | 25 | 2 TB | enhanced | 79 € | 790 € |
| **Business** | 175 GB | 200 GB | 25 GB | 12 GB | 6 | 50 | 4 TB | high | 139 € | 1.390 € |
| **Enterprise** | ab 500 GB | eigener Server | — | n. V. | n. V. | 100+ | n. V. | dedicated | Angebot | — |

### Kopfraum: `max(10 GB, 12 %)`

Die Platte trägt Debian, Docker, die Nextcloud-Datenbank, Dateiversionen,
Papierkorb und Protokolle mit. Der Bedarf daran wächst mit der Instanz: eine
175-GB-Nextcloud mit 50 Nutzern hat eine erheblich größere Datenbank und mehr
Versionsstände als eine mit drei Leuten. Ein flacher Aufschlag wäre unten
verschwenderisch und oben zu knapp — und eine volle Platte legt nicht den Upload
still, sondern die ganze Instanz.

Die Regel ist eine **Untergrenze**; die Zahlen oben sind auf die nächste runde
Größe abgerundet (streng gerechnet wären es 88 und 176 GB Kontingent). Die Platte
von Intern bleibt bei den heutigen 20 GB: eine virtuelle Platte wächst nur, sie
schrumpft nie, und es gibt keinen Grund, für ein Abnahmepaket enger zu bauen als
das, was schon dasteht.

### Merkmale

- **Intern** — Wartung, tägliche Sicherung, Überwachung, Adresse, eigene Domain.
  Die eigene Domain absichtlich mit dabei: das Paket ist für Abnahmeläufe da und
  muss den Weg prüfen können, den ein Business-Kunde geht.
- **Start** — Wartung, tägliche Sicherung, Überwachung, Adresse `ihrefirma.<zone>`.
  Keine eigene Domain, auch nicht dazubuchbar (`unavailable_on` in
  `config/provisioning.php`, gelesen allein von `CustomDomainAccess`).
- **Team** — dazu Office, eigenes Erscheinungsbild, bevorzugter Support. Eigene
  Domain als Modul für 9 €.
- **Business** — dazu eigene Domain inklusive, längere Aufbewahrung,
  Zugriffsprotokoll.
- **Enterprise** — unverändert wie heute, nur nicht mehr im Verkauf.

### Was die Leiter über die Flotte sagt

Ein Host voll Start bringt 351 €, voll Team 237 €, voll Business 139 €. Der
Mengenrabatt nach oben kostet auf einer speichergebundenen Maschine Geld. Das ist
bewusst so beschlossen und hier notiert, damit es niemand später für einen
Rechenfehler hält: solange die Hardware 456 GB hat, verdient die kleinste Stufe
am meisten.

## Zusatzspeicher

**20 GB nutzbar für 15 € netto, höchstens 3 Blöcke**, buchbar bei Start, Team und
Business — nicht bei Enterprise (eigene Maschine). Ein Block belegt **22 GB
Platte**: 20 für den Kunden, 2 als Kopfraum, damit die 12-%-Regel auch für ein
gestapeltes Paket gilt und nicht nur für ein frisch gekauftes.

Heute steht dort `100 GB für 10 €` — 0,10 €/GB, **unter Einstandspreis** (roh
0,18 €/GB bei voller Auslastung, realistisch 0,23 €) und billiger als jeder
Schritt nach oben.

Die Preisregel, aus der die 15 € folgen: **ein Block muss teurer je GB sein als
der Aufstieg in die nächste Stufe.** Sonst stapeln Kunden Zusätze, statt
aufzusteigen, und belegen den knappsten Rohstoff zum niedrigsten Preis.

| | GB | € | €/GB |
|---|---|---|---|
| Aufstieg Start → Team | +55 | +40 | 0,73 |
| Aufstieg Team → Business | +90 | +60 | 0,67 |
| **Block** | **20** | **15** | **0,75** |

Der Deckel bei drei liegt dort, wo Aufsteigen billiger wird: Start + 3 Blöcke =
90 GB für 84 €, Team = 85 GB für 79 € mit 25 statt 10 Nutzern und 8 statt 6 GB
RAM. Die Zahlen drängen nach oben, niemand muss.

### Drei Reparaturen, ohne die der Block nicht taugt

**1. Der Deckel gehört in `BookAddon`, nicht ins Formular.** Gezählt wird über
alle aktiven Blöcke der Verträge hinweg, nicht je Buchung — sonst sind drei
Buchungen à 1 Block neun Blöcke. Dieselbe Lektion wie bei `ReissueTakeover`
(v1.3.82): die Ansicht versteckte den Knopf, die Methode prüfte nichts.

**2. 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.
Der Host muss also nein sagen dürfen. Gefragt wird `Host::canTake()` für den Host,
auf dem die Instanz **bereits läuft** — nicht `placeableIn()`: eine laufende
Instanz zieht nicht um, es gibt keinen Umzug zwischen Hosts. Reicht der Platz
nicht, wird die Buchung abgelehnt, bevor Geld fließt.

**3. Die Packungsgröße gehört auf die Buchung.** `StorageAllowance::packSizeGb()`
liest sie heute live aus der Konfiguration; im Kopfkommentar steht wörtlich, was
das bedeutet: *„if the owner ever re-cuts the pack from 100 GB to 200 GB,
existing bookings would grow with it … the day it does, the size belongs on the
booking beside the price."* Dieser Tag ist heute. Ohne diese Änderung schrumpft
jeder gekaufte 100-GB-Block in der Sekunde der Umstellung auf 20 GB — bei einem
Kunden, der bereits Daten darin liegen hat.

Also zwei Spalten auf `subscription_addons`: `pack_gb` (nutzbar) und
`pack_disk_gb` (belegt). Bestehende Zeilen werden mit 100 und 100 nachgetragen —
das ist, was verkauft wurde. Gelesen wird ab dann von der Buchung, nicht aus der
Konfiguration. In der Entwicklungsdatenbank ist kein Block gebucht; in der
Livedatenbank kann das anders sein, deshalb ist der Nachtrag Teil der Migration
und nicht wegzulassen.

### Was ein Block auf der Platte bewirkt

`ResizeVirtualMachine` rechnet heute
`allowance->totalGb() + max(0, planDisk - planQuota)`. Neu kommt der Kopfraum der
Blöcke dazu, aus den eingefrorenen Zahlen der Buchungen:

```
Ziel = Kontingent des Pakets
     + Σ (pack_gb je aktiver Block)
     + (planDisk − planQuota)              // Kopfraum des Pakets
     + Σ (pack_disk_gb − pack_gb je Block) // Kopfraum der Blöcke
```

Start + 3 Blöcke: 30 + 60 + 10 + 6 = **106 GB Platte für 90 GB Kontingent**.

### Auf einem Jahresvertrag

Bereits gebaut, hier nur festgehalten, weil es auf den Buchungsbildschirm gehört
und dort heute fehlt: `AddonPrices` prägt den Stripe-Preis eines Moduls **im Takt
des Vertrags** (jährlich = 12 × monatlich netto), Hinzunahmen gehen mit
`PRORATE_IMMEDIATELY` hinein. Wer im siebten Monat bucht, bekommt sofort eine
Rechnung über den Rest des Jahres, danach läuft der Block mit dem Vertrag weiter
(180 €/Jahr). Eine Kündigung geht mit `PRORATE_NONE`: der Block läuft bis zum
Ende des bezahlten Zeitraums, ohne Gutschrift.

**Module bekommen die zwei Freimonate nicht.** Die Freimonate sind der Hebel für
den Paketabschluss; ein Modul wird mitten im Jahr gebucht und ist keine
Abschlussentscheidung. `AddonPrices::termNetCents` bleibt bei 12 ×, für alle
Module gleich.

## Das interne Paket

Anzeigename **„Intern"**, Notiz „Nur für interne Tests, nicht im Verkauf".

Der Schlüssel bleibt `test`. `PlanFamily` verbietet das Umbenennen ausdrücklich —
Bestellungen, Instanzen und jeder Vertragsschnappschuss verweisen darauf, und
umbenennen hieße nicht sie mitziehen, sondern sie verwaisen lassen.

Sichtbarkeit über ein neues Feld `internal` auf `plan_families`:

- `PlanCatalogue::sellable()` lässt interne Familien aus. Damit verschwindet das
  Paket aus Preisblatt (`LandingController`), Bestellung (`Order`) und Warenkorb
  (`Checkout`), die alle dieselbe Liste lesen.
- `PlanCatalogue::currentVersion()` liefert sie weiterhin — sonst wäre
  **Kunde → Paket schenken** (`GrantPlan`) für das interne Paket tot, und genau
  dafür existiert es.
- `CheckoutController` lehnt einen internen Schlüssel ausdrücklich ab, statt sich
  darauf zu verlassen, dass er in keiner Liste steht. Es ist die einzige Stelle,
  an der ein Schlüssel **von außen** in `currentVersion()` läuft; die übrigen
  Aufrufer sind der Admin-Weg (`GrantPlan`, `GrantSubscription`) und
  `Subscription` selbst, und die sollen das interne Paket ja bekommen.

`sales_enabled` ist dafür der falsche Hebel: das schaltet auch das Verschenken ab
(`currentVersion()` wirft bei `sales_enabled = false`).

## Enterprise und die Reservierung

Enterprise geht auf `sales_enabled = false` und bekommt auf dem Preisblatt einen
Anfrage-Block statt eines Preises. Nicht aus Geschmack: 500 GB finden auf einem
Host mit 388 GB vergebbarem Platz keinen Platz, `placeableIn()` liefert nichts,
und der Kunde erführe das **nach** der Zahlung — die Bestellung parkt und wird
nach der Frist abgebrochen.

Damit „eigener Server" mehr ist als ein Wort, fehlt eine Reservierung. Heute
nimmt `Host::placeableIn()` jeden aktiven Host im Rechenzentrum, und
`HostCapacity::hasRoomFor()` und `largestPlaceableGb()` **zählen** eine exklusiv
verkaufte Maschine obendrein mit — der Shop verspräche also Platz, der bereits
vergeben ist, und die nächsten Startkunden landeten darauf.

- Neues Feld `hosts.reserved_for_customer_id` (nullable, Fremdschlüssel;
  `null` = allgemeiner Bestand).
- `placeableIn($datacenter, $diskGb, ?int $forCustomerId = null)` überspringt
  reservierte Hosts, außer die Platzierung gehört dem Kunden selbst.
- `hasRoomFor()` und `largestPlaceableGb()` rechnen ohne reservierte Hosts. Die
  Kapazitätsseite im Admin zeigt sie getrennt, damit die Maschine nicht
  verschwindet, sondern erklärt dasteht.
- Die Host-Seite zeigt die Reservierung und erlaubt sie zu setzen und zu lösen.
- Das Festnageln einer Bestellung auf einen Host gibt es in `ReserveResources`
  bereits (`$pinned->canTake(...)`); die Reservierung ist die fehlende zweite
  Hälfte, die alle anderen fernhält.

## Der Jahrespreis

Nichts zu bauen. Auf jeder Paketversion sitzt `free_months`, im Admin unter
`/admin/plans/{uuid}` einstellbar, und der Jahresbetrag wird daraus gerechnet
(`monatlich × (12 − Freimonate)`, `PlanVersions::yearlyCents`) statt als zweite
freie Zahl getippt. Deshalb kann die Zeile „zwei Monate frei" auf dem Preisblatt
nicht von dem abweichen, was Stripe abbucht. Für alle neuen Versionen: **2**.

## Die Umschaltung

Veröffentlichte Versionen sind unveränderlich; jede neue Größe und jeder neue
Preis ist zwingend eine neue Version.

1. `v1` bei `test`, `start`, `team`, `business` bekommt `available_until` auf den
   Umschaltzeitpunkt, `v2` ein `available_from` auf **dieselbe Sekunde**. Das
   Fenster ist halboffen (`available_from <= now < available_until`), also gibt
   es keine Überschneidung und keinen Job, der laufen müsste.
2. `enterprise` bekommt `sales_enabled = false`. Die Familie bleibt stehen —
   löschen ist verboten, solange veröffentlichte Versionen daran hängen.
3. `test` bekommt `internal = true` und den Anzeigenamen „Intern".
4. Danach `php artisan plans:check` (Lücken, Überschneidungen, fehlende Preise)
   und `php artisan stripe:sync-catalogue --dry-run`, bevor irgendetwas gegen
   Stripe läuft.

Ausgeführt wird das als **ein Artisan-Befehl in einer Transaktion**
(`plans:switch-catalogue`, mit `--dry-run`), nicht als Migration und nicht per
Hand in der Admin-Oberfläche: vier alte Versionen enden, vier neue beginnen, acht
Preise entstehen und eine Familie verlässt den Verkauf — entweder alles oder
nichts. Der Befehl ist wiederholbar und bricht ab, wenn bereits umgeschaltet
wurde.

### Was mit dem Bestand passiert

Die fünf laufenden Verträge (2× Team, 1× Start, 1× Business, 1× Team) fahren auf
ihren **eingefrorenen Schnappschüssen** weiter und merken nichts: seit dem
Katalogumbau liest kein Vertrag mehr den Katalog. Ihre alten Stripe-Preise
bleiben stehen, solange sie darauf laufen; `stripe:sync-catalogue` legt nur die
neuen an, aufgeräumt wird später mit `stripe:sweep-orphan-prices`.

### Geprüft: der Vertrag friert ein, das Modul nicht

Die Frage ist berechtigt und war zu klären, bevor irgendetwas gebaut wird: löst
ein laufender Vertrag seine Größen zur Laufzeit auf, schriebe die Umschaltung
einem Startkunden mit 100 GB ein Kontingent von 30 GB auf eine Cloud mit
60 GB Inhalt — kein Upload mehr, für einen zahlenden Kunden, ohne dass jemand
etwas getan hat.

Nachgesehen, Stand 1. August 2026:

- `Subscription::snapshotFrom()` kopiert Kontingent, Platte, RAM, Kerne, Plätze,
  Traffic und Preis beim Kauf auf die Vertragszeile und hält in
  `plan_version_id` fest, unter welcher Version verkauft wurde.
- Aufgerufen wird sie von `OpenSubscription` (Kauf), `PlanChange` (Vorschau) und
  `ApplyPlanChange` (der Kunde wechselt selbst) — von **keinem** Lauf, der eine
  bestehende Instanz anfasst.
- `CustomerStep::plan()` liest die Vertragsspalten und fällt bei fehlendem
  Vertrag auf ein leeres Feld zurück, damit `ValidateOrder` hart abbricht.
- `StorageAllowance::for()` nimmt `instances.quota_gb`, hilfsweise das
  eingefrorene `quota_gb` des Vertrags. Den Katalog fragt sie nie.

**Ergebnis: die Pakete sind sicher, die Module sind es nicht.** Auf
Vertragsebene ist diese Lektion bereits gebaut; auf Modulebene steht die
Packungsgröße live in der Konfiguration. Die Reihenfolge — erst die drei
Reparaturen, dann die Umschaltung — gilt deshalb für die Blöcke, und für die
gilt sie zwingend.

Zwei Folgen, die genannt gehören:

- Ein Bestandskunde auf altem Team (500 GB) wäre auf neuem Team (85 GB) eine
  Verkleinerung. `DowngradeCheck` lehnt das ab, wenn mehr gespeichert ist als
  hineinpasst — richtig so, aber es wird passieren und es wird erklärt werden
  müssen.
- Das Preisblatt ändert sich öffentlich: Start fällt von 100 GB auf 30 GB und von
  49 € auf 39 €.

## Was angefasst wird

| Datei | Änderung |
|---|---|
| `database/migrations/…_add_internal_to_plan_families.php` | Feld `internal` |
| `database/migrations/…_add_pack_size_to_subscription_addons.php` | `pack_gb`, `pack_disk_gb` + Nachtrag |
| `database/migrations/…_add_reservation_to_hosts.php` | `reserved_for_customer_id` |
| `app/Console/Commands/SwitchPlanCatalogue.php` | neu: die Umschaltung |
| `app/Services/Billing/PlanCatalogue.php` | `sellable()` lässt interne aus |
| `app/Http/Controllers/CheckoutController.php` | interner Schlüssel wird abgelehnt |
| `app/Services/Billing/StorageAllowance.php` | Größe von der Buchung statt aus config |
| `app/Services/Billing/AddonCatalogue.php` | Deckel und `unavailable_on` für Speicher |
| `app/Actions/BookAddon.php` | Deckel prüfen, `canTake()` fragen, Größe einfrieren |
| `app/Provisioning/Steps/Customer/ResizeVirtualMachine.php` | Kopfraum der Blöcke |
| `app/Models/Host.php` | `placeableIn()` mit Reservierung |
| `app/Services/Provisioning/HostCapacity.php` | reservierte Hosts aus dem Bestand |
| `app/Livewire/Admin/HostDetail.php`, `Capacity.php` | Reservierung zeigen und setzen |
| `app/Livewire/ConfirmBookStorage.php` | Deckel, Rest-Jahr-Hinweis |
| `app/Http/Controllers/LandingController.php` | Enterprise als Anfrage-Block |
| `config/provisioning.php` | `storage_addon`: 20/22 GB, 1500, `max` 3, `unavailable_on` |
| `lang/de`, `lang/en` | Texte für Deckel, Reservierung, Anfrage, Rest-Jahr |

## Tests

- **Katalog** — nach der Umschaltung verkauft `sellable()` genau drei Pakete;
  `test` und `enterprise` sind nicht dabei; `v1` und `v2` überschneiden sich in
  keiner Sekunde; ein Bestandsvertrag behält Größe und Preis.
- **Intern** — `currentVersion('test')` liefert weiterhin (Verschenken lebt);
  `CheckoutController` weist den Schlüssel ab; das Preisblatt kennt ihn nicht.
- **Block** — der vierte Block wird in `BookAddon` abgelehnt, nicht erst im
  Formular; drei Buchungen à 1 Block zählen als drei; eine Buchung auf einem
  vollen Host wird abgelehnt; Enterprise kann keinen buchen.
- **Eingefrorene Größe** — eine Buchung über 100 GB bleibt 100 GB, nachdem die
  Konfiguration auf 20 GB gestellt wurde.
- **Platte** — Start + 3 Blöcke ergibt 106 GB Ziel; ohne Blöcke unverändert
  40 GB; ein Wiederholungslauf vergrößert nicht zweimal.
- **Reservierung** — ein reservierter Host nimmt keinen fremden Kunden; er zählt
  nicht in `largestPlaceableGb()`; der Kunde, für den er reserviert ist, landet
  darauf.
- **Jahresvertrag** — ein Block auf einem Jahresvertrag bekommt einen
  Jahres-Preis über 12 × 15 €, keine Freimonate.

## Nicht Teil dieses Entwurfs

- **Das Anwenden von Paketwechseln.** `PlanChange` ist weiterhin nur eine
  Vorschau; Auf- und Abstiegsbestellungen werden erzeugt und von nichts
  verbraucht. Diese Lücke ist älter als dieser Entwurf und wird von ihm weder
  geschlossen noch vergrößert.
- **Der Umzug zwischen Hosts** und jede Form von Überbuchung. Beides setzt
  gemessenen echten Verbrauch je Instanz voraus, den CluPilot heute nicht kennt.
  Reihenfolge, falls es je kommt: erst der Umzug, dann die Überbuchung.
- **Neue Hardware.** Der größte Hebel, aber eine Beschaffungsentscheidung.

Siehe `docs/superpowers/specs/2026-07-30-stripe-price-adoption-design.md` und den
Katalogumbau in `docs/handoff-plan-catalogue.md`.
