CluPilotCloud/docs/superpowers/specs/2026-08-01-neue-pakete-desi...

18 KiB
Raw Blame History

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.

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.
  • InterncurrentVersion('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.