18 KiB
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 Beschaffungsfrage 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_oninconfig/provisioning.php, gelesen allein vonCustomDomainAccess). - 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.CheckoutControllerlehnt 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 incurrentVersion()läuft; die übrigen Aufrufer sind der Admin-Weg (GrantPlan,GrantSubscription) undSubscriptionselbst, 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()undlargestPlaceableGb()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
ReserveResourcesbereits ($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.
v1beitest,start,team,businessbekommtavailable_untilauf den Umschaltzeitpunkt,v2einavailable_fromauf dieselbe Sekunde. Das Fenster ist halboffen (available_from <= now < available_until), also gibt es keine Überschneidung und keinen Job, der laufen müsste.enterprisebekommtsales_enabled = false. Die Familie bleibt stehen — löschen ist verboten, solange veröffentlichte Versionen daran hängen.testbekommtinternal = trueund den Anzeigenamen „Intern".- Danach
php artisan plans:check(Lücken, Überschneidungen, fehlende Preise) undphp 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 inplan_version_idfest, unter welcher Version verkauft wurde.- Aufgerufen wird sie von
OpenSubscription(Kauf),PlanChange(Vorschau) undApplyPlanChange(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, damitValidateOrderhart abbricht.StorageAllowance::for()nimmtinstances.quota_gb, hilfsweise das eingefrorenequota_gbdes 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.
DowngradeChecklehnt 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;testundenterprisesind nicht dabei;v1undv2überschneiden sich in keiner Sekunde; ein Bestandsvertrag behält Größe und Preis. - Intern —
currentVersion('test')liefert weiterhin (Verschenken lebt);CheckoutControllerweist den Schlüssel ab; das Preisblatt kennt ihn nicht. - Block — der vierte Block wird in
BookAddonabgelehnt, 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.
PlanChangeist 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.