From e4d4fd0cfb6824398c401c9496c6f35330bf0773 Mon Sep 17 00:00:00 2001 From: nexxo Date: Sat, 1 Aug 2026 11:50:42 +0200 Subject: [PATCH] Entwurf: die neuen Pakete MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../specs/2026-08-01-neue-pakete-design.md | 310 ++++++++++++++++++ 1 file changed, 310 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-01-neue-pakete-design.md diff --git a/docs/superpowers/specs/2026-08-01-neue-pakete-design.md b/docs/superpowers/specs/2026-08-01-neue-pakete-design.md new file mode 100644 index 0000000..112de42 --- /dev/null +++ b/docs/superpowers/specs/2026-08-01-neue-pakete-design.md @@ -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 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.`. + 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`.