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`.