Entwurf: die neuen Pakete

Die Leiter wird auf die Maschine zugeschnitten, die dasteht. Speicher bindet die
Kundenzahl je Host, nicht Rechenleistung: die alte Leiter band bei drei Kunden je
Server und ließ sechs RAM-Plaetze verfallen. Neu sind es neun Kunden a 40 GB
Platte, 351 EUR statt 147 EUR je Server.

Kopfraum waechst mit: max(10 GB, 12 %). Eine 175-GB-Nextcloud mit 50 Nutzern hat
eine groessere Datenbank und mehr Versionsstaende als eine mit drei Leuten, und
eine volle Platte legt nicht den Upload still, sondern die Instanz.

Der Zusatzblock geht auf 20 GB fuer 15 EUR und ist bei drei gedeckelt. Bei 12 EUR
waere er mit 0,60 EUR/GB billiger gewesen als jeder Aufstieg (0,73 und 0,67) —
dann stapeln Kunden, statt umzusteigen, und belegen den knappsten Rohstoff zum
niedrigsten Preis. Ein Block belegt 22 GB Platte, damit die Kopfraum-Regel auch
fuer ein gestapeltes Paket gilt.

Drei Reparaturen haengen daran, und eine davon steht seit laengerem im Code
angeschrieben: StorageAllowance liest die Packungsgroesse live aus der
Konfiguration, mit dem Vermerk, dass sie an dem Tag auf die Buchung gehoert, an
dem sie sich bewegt. Dieser Tag ist heute — ohne die Aenderung schrumpft ein
gekaufter 100-GB-Block in der Sekunde der Umstellung auf 20 GB.

Das Testpaket heisst Intern und verschwindet aus dem Verkauf, bleibt aber
verschenkbar. Enterprise verlaesst den Shop, weil 500 GB auf 388 GB keinen Platz
finden und der Kunde das erst nach der Zahlung erfuehre. Und damit "eigener
Server" mehr ist als ein Wort: Hosts bekommen eine Reservierung — heute nimmt
placeableIn() jeden aktiven Host, und die Kapazitaetszahlen zaehlen eine exklusiv
verkaufte Maschine mit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/hostnamen-vergabe
nexxo 2026-08-01 11:50:42 +02:00
parent 5cae870dd9
commit e4d4fd0cfb
1 changed files with 310 additions and 0 deletions

View File

@ -0,0 +1,310 @@
# 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 6080 € 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`.
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`.