# Handoff — vom grünen Test zum ersten echten Durchlauf

**Stand:** 2026-07-30, `main` auf `v1.3.44`, **1686 Tests grün.**
**Zweck:** In dieser Nacht wurden Host-Onboarding, VM-Erstellung und der Stripe-Prozess
vollständig geprüft und der größte Teil der Funde behoben. Was bleibt, ist unten
beschrieben. Die Kernaussage in einem Satz: **die Testsuite war grün, und trotzdem
hätte kein Host durchinstalliert und keine VM ausgeliefert werden können.**

---

## 0. Die Lektion, die den ganzen Rest erklärt

Alle Funde waren gegen eine grüne Suite unsichtbar, und zwar systematisch:

- `HostOnboardingEndToEndTest` fährt die ganze Pipeline gegen `FakeRemoteShell`,
  dessen Standardantwort **Erfolg** ist. Der Test beweist, dass die Zustandsmaschine
  richtig durchzählt — über das Verhalten irgendeines Befehls sagt er nichts.
- Rund ein Dutzend Tests schrieben in `config('provisioning.plans.*')`, einen
  Schlüssel, den es seit dem Katalog-Umbau nicht mehr gibt (und es existiert ein
  Test, der genau das behauptet: `expect(config('provisioning.plans'))->toBeNull()`).
  Jede dieser Zeilen war wirkungslos; die Tests waren aus dem falschen Grund grün.
- Mehrere Tests prüften eine Spalte, die der Code eine Zeile vorher selbst
  geschrieben hatte.

**Konsequenz für die nächste Session:** Ein Testlauf ist keine Abnahme. Jeder
Arbeitsblock unten endet mit einem Schritt auf echter Hardware, nicht mit „grün".

---

## 1. Entscheidungen aus dem Brainstorming (verbindlich)

| Frage | Entscheidung |
|---|---|
| Woher kommt die Software im Gast? | **Vorlage bringt Docker + Compose mit, Nextcloud installiert sich beim ersten Start** aus `NEXTCLOUD_ADMIN_*`, die die Pipeline in die `.env` schreibt. Vorlage bleibt kundenunabhängig, Passwörter werden nie in ein Image gebacken. |
| Proxmox-Version | **PVE 9 auf Debian 13 trixie.** Die Codename-Tabelle in `InstallProxmoxVe` kennt beides; PVE 8/bookworm kann bleiben, getestet und unterstützt wird 9. |
| Traefik | **Systemdienst (Binary + systemd-Unit), pro Host**, installiert *und geprüft* von einem neuen Pipeline-Schritt. Kein Docker auf dem Hypervisor — nftables bleibt einziger Eigentümer der Host-Firewallregeln. |
| `vmbr0` | **Vollautomatisch**, mit Selbstrücknahme-Zeitgeber (siehe Block B). |
| Testumgebung | **Ein dedizierter Server mit Debian 13**, neu aufsetzbar. Kein zweiter Rechner nötig. |
| Testpaket | Eigene Paketfamilie **„Test"**: 20 GB Platte, 1 GB RAM, 1 Kern, 5 GB Kontingent. Nach dem Beweis wieder aus dem Verkauf nehmen. |
| ACME | HTTP-01 pro Host. **Kein** Wildcard/DNS-01 — pro Kunde entsteht genau ein Zertifikat, unabhängig von der Host-Zahl. |

**Nicht geeignet:** Hetzner Cloud (CPX/CX) — keine verschachtelte Virtualisierung,
also `/dev/kvm` nicht vorhanden, also startet kein Gast. Unabhängig von der Größe.
Bei netcup-VPS ist es produktabhängig; entscheidend ist
`grep -cE 'vmx|svm' /proc/cpuinfo && ls -l /dev/kvm`.

---

## 2. Was heute Nacht behoben und ausgeliefert wurde

Sechs Commits, `8440266` → `9da1358`, Tag `v1.3.44`.

### Stripe / Geld
- **Nichts konnte ein Stripe-Abo beenden.** Kündigung und Widerruf schrieben nur
  lokale Zustände; Stripe erfuhr nie davon, und die nächste Abbuchung wurde
  **als echte Rechnung aus der lückenlosen Serie ausgestellt und versendet** —
  monatlich, unbegrenzt, für eine abgeschaltete Leistung. Jetzt: Kündigung zum
  Periodenende, Widerruf sofort, und eine Rechnung wird nur noch ausgestellt, wenn
  ihr Abrechnungszeitraum **vor** dem Vertragsende begann.
- Kündigung lief auf **Monatstakt auch bei Jahresverträgen** (bis zu elf bezahlte
  Monate verloren). Jetzt aus `current_period_end`.
- **Einrichtungsgebühr** wurde überall beworben und **nie berechnet** — jetzt eine
  Einmalposition am Checkout, brutto, auf der ersten Rechnung.
- Das Kundenportal zeigte **fünf erfundene Rechnungen**, eine erfundene
  Ausgabenkurve und eine erfundene nächste Abbuchung, während die echten Belege
  existierten und nirgends im Portal auftauchten. Jetzt die echten.
- **Widerruf erstattet voll** (Ihre Entscheidung), Stornorechnung für **jede**
  Abbuchung im Zeitfenster, nicht nur die erste.
- Ein Absturz nach dem Auftrags-Commit ließ einen bezahlten Kauf **dauerhaft
  unfakturiert** — `resume()` holt das jetzt nach, idempotent.
- Klick auf „Upgrade" löschte stillschweigend eine vorgemerkte Abstufung und tat
  sonst nichts. Behoben.
- **Reverse-Charge-Kunden zahlten 20 % zu viel**: ein Stripe-Preis für alle, also
  Bruttobetrag auch für den, dem keine Steuer berechnet wird — und der Beleg wies
  den ganzen Betrag als Netto aus. Jetzt **zwei Preise je Paket** (brutto/netto),
  Auswahl an der Kasse über `TaxTreatment`.
- **UID-Prüfung gegen VIES** gebaut (vorher setzte niemand `vat_id_verified_at`,
  Reverse Charge konnte also nie auslösen). Drei Ergebnisse: bei „Register nicht
  erreichbar" wird **nichts** geschrieben.

### Host-Onboarding
- **Debian-Codename war fest auf `bookworm`** — ein heutiger Server hätte PVE-8-Quellen
  gegen Debian 13 bekommen. Jetzt aus `/etc/os-release`, unbekannte Releases brechen ab.
- **WireGuard-Sackgasse:** Die Tunnel-IP wurde gespeichert, bevor der Tunnel bewiesen
  war; danach wählte *jeder* Verbindungsversuch inklusive Konsolen-Wiederholung diesen
  kaputten Tunnel, und der reparierende Code war unerreichbar. Rettung nur per
  Datenbank-Handarbeit. Jetzt gilt der Tunnel erst mit bewiesenem Handshake.
- **wg0 hätte den Neustart nicht sicher überlebt** (`||`-Fallback verdeckte die
  fehlende systemd-Aktivierung); **der Neustart konnte eine nicht mehr bootende
  Maschine hinterlassen**; **`ConfigureProxmox` tat nichts** (kein `vmbr0`, Datacenter-
  Firewall aus → die „nur 80/443"-Regeln der Kunden-VMs waren **wirkungslos**);
  der Rolle fehlte **`Sys.Modify`** (jede Kundenbereitstellung wäre am Backup-Schritt
  gestorben); nftables verwarf **jedes ICMP** (IPv6 und PMTUD gebrochen);
  `api_token_ref` hing an `APP_KEY` statt am `SECRETS_KEY`.
- Neu: **`VerifyVmTemplate`** — ein Host geht nicht mehr auf „aktiv", wenn die
  Vorlage fehlt. Vorher starb der erste bezahlte Auftrag *nach* der Zahlung.

### VM-Erstellung
- **Das 14-tägige Kapazitätsparken war unerreichbar:** Abfrageintervall 120 s gegen
  Schritt-Zeitlimit 60 s, also lief jeder Wiedereinstieg in eine Zeitüberschreitung.
  Ein bezahlter Auftrag ohne freien Host war nach **sechs Minuten** ein Fehlerfall.
  Die ganze Warteschlange war toter Code.
- **Jeder `occ`-Aufruf lief als root** statt `www-data`; Nextcloud verweigert das.
  Jetzt ein gemeinsamer Erbauer (`App\Support\NextcloudOcc`) plus ein Test, der
  jede neue `docker compose exec`-Stelle außerhalb davon verbietet.
- Ein **einzelner zuckender HTTPS-Test verurteilte eine fertige Cloud** zum Fehlerfall.
- Eine nachgewiesene eigene Domain wurde **nie geroutet**, wenn zufällig ein Neustart
  lief; gekaufter Speicher wurde **abgerechnet und nie geliefert**. Der „läuft schon
  etwas?"-Schutz fragt jetzt, ob der laufende Lauf die Arbeit **tatsächlich abdeckt**.
- Die `address`-Pipeline lief in der falschen Reihenfolge (Zertifikat vor
  vertrauenswürdigem Hostnamen → gültiges Zertifikat auf Nextclouds
  „untrusted domain"-Fehlerseite).
- Eine geänderte Gast-IP war **unreparierbar** (502 ohne Weg zurück).
- Der Hetzner-DNS-Abgleich las **nur die erste Seite** → ab ~100 Einträgen ein
  **zweiter A-Record** für denselben Namen.

---

## 3. Was noch zu tun ist — vier Blöcke, in dieser Reihenfolge

### Block A — Goldene VM-Vorlage (kritischer Pfad, blockiert alles)
Es gibt **keine** Vorlage auf `template_vmid 9000`. Ohne sie kann nichts ausgeliefert
werden. Zu tun:
- Debian-13-VM auf dem dedizierten Server, ~20 GB Platte, cloud-init-fähig
  (`qemu-guest-agent` **muss** drin sein — `WaitForGuestAgent` hängt daran).
- Docker + Compose, `/opt/nextcloud/` mit `docker-compose.yml`, das
  `NEXTCLOUD_ADMIN_USER`/`NEXTCLOUD_ADMIN_PASSWORD`/`MYSQL_PASSWORD` aus `.env` liest
  und beim ersten Start selbst installiert.
- **`user: www-data` am `app`-Dienst** — sonst schlagen alle `occ`-Aufrufe fehl.
- Partition als **letzte** auf der Platte, **kein LVM** — `GrowGuestFilesystem` kann
  eine nicht-letzte Partition nicht vergrößern und erkennt LVM bewusst nicht
  (es würde sonst ein Kontingent über ein nicht gewachsenes Dateisystem schreiben).
- Als Proxmox-Vorlage konvertieren, vmid 9000.
- **Schriftliches Runbook dazu**, versioniert im Repo. Die Vorlage ist ab dann Ihr
  wichtigstes Artefakt und gehört nicht in mündliches Wissen.

### Block B — `vmbr0` vollautomatisch (riskantester Code des Vorhabens)
Eigener Schritt, eigener Abnahmetest. Muster:
1. Aktuelle Netzkonfiguration sichern.
2. Systemd-Timer auf 5 Minuten, der sie zurückspielt und neu startet.
3. Brücke aktivieren.
4. **Von außen** prüfen, ob der Host antwortet.
5. Erst dann den Timer abbestellen.

Der Schritt muss **erkennen**, was er vorfindet — DHCP auf einer Schnittstelle
(Cloud) gegen geroutete Einzel-IP mit Gateway außerhalb des Subnetzes
(Hetzner-Dedicated) gegen netcup. Eine Brücke, die für den einen Fall richtig ist,
nimmt den anderen vom Netz. `ConfigureProxmox` bricht heute bewusst mit einer
Anleitung ab; dieser Block ersetzt das.

### Block C — Traefik als Systemdienst
Neuer Schritt zwischen `SecureHostFirewall` und `CompleteHostOnboarding`:
- Binary installieren, systemd-Unit, statische Konfiguration mit `websecure`-Entrypoint
  und `letsencrypt`-certResolver, **deren Namen zu dem passen müssen, was
  `SshTraefikWriter::render()` schon ausgibt** — hier zuerst nachlesen, nicht raten.
- Dynamisches Verzeichnis anlegen (`TRAEFIK_DYNAMIC_PATH`, Vorgabe
  `/etc/traefik/dynamic`), Dateiprovider darauf zeigen.
- **Prüfen, nicht nur installieren:** Dienst läuft, 80/443 belegt, Ping-Endpunkt
  antwortet. Ein Host darf nicht „aktiv" werden, wenn das Routing nicht steht.
- ACME braucht Port 80 **erreichbar** — die Firewall öffnet ihn, aber Traefik muss
  lauschen, bevor das erste Zertifikat angefordert wird.

### Block D — Kleinigkeiten und eine kaufmännische Entscheidung
- **Rollen-Konvergenz auf bereits aktiven Hosts.** `Sys.Modify` erreicht sie nicht:
  ein abgeschlossenes Onboarding kann nicht wiederholt werden, ein Kommando fehlt.
- **Traffic-Pakete (Ihre Entscheidung):** Der Auftrag ist einmalig, das Feld
  `traffic_addons` ist ein *dauerhafter* Zähler. Hochzählen = 1000 GB für immer aus
  einer Zahlung; nichts tun = der Kunde zahlte für nichts. Der Code protokolliert
  das derzeit laut, statt still eines von beiden zu tun.
- `pveum acl modify … || true` und `pveum user add … || true` verdecken weiter
  Fehler — dieselbe Klasse wie der behobene `role add`.
- `VerifyVmTemplate` prüft nur **Existenz**, nicht `template: 1`.
- `applyFirewall()` setzt **nicht** `firewall=1` an `net0` der VM — ohne das greifen
  die Gastregeln trotz Datacenter-Firewall nicht. Prüfen, ob die Vorlage es mitbringt.
- ~~`stripe_addon_prices.stripe_price_id` ist **nicht** eindeutig (Plan-Seite schon):
  zwei Zeilen könnten einen Preis teilen, Archivieren würde den anderen mitentziehen.~~
  **Erledigt** — eindeutig seit `2026_07_31_210000_one_row_per_stripe_price`; zugleich
  das Netz unter dem Wiedererkennungsschritt, siehe
  `docs/superpowers/specs/2026-07-30-stripe-price-adoption-design.md`.
- `clupilot:end-due-services` schließt den **Vertrag** nicht — ein geschenkter Vertrag
  bleibt nach Ende der Instanz für immer `active` und zählt in Umsatz/Dashboard mit.
- Eine Abbuchung, die **vor** dem Widerruf entstand und **danach** bezahlt wird,
  bekommt einen Beleg, wird aber nicht storniert oder erstattet — ohne Signal.
- `Livewire\Admin\Provisioning::isStale()` erbt das neue 14-Tage-Budget: ein geparkter
  Lauf mit totem Worker wird zwei Wochen nicht als hängend gemeldet.

---

## 4. Was gesetzt sein muss, bevor ein echter Lauf Sinn hat

Auf **diesem** Entwicklungsserver ist alles vorhanden; auf dem Live-Server ist es
ungeprüft. Vor dem Durchlauf einzeln bestätigen — jedes fehlende Stück bricht die
Kette an einer anderen Stelle, und mehrere brechen sie **nach** der Zahlung.

**Geheimnisse (Vault, Konsole → Integrationen):**
- `ssh.private_key` — ohne: `EstablishSshTrust` scheitert mit einem phpseclib-Parserfehler
- `dns.token` — Hetzner-DNS-Token. Ohne: der A-Record der VM entsteht nicht, der Kunde
  hat keine Adresse. **Braucht Schreibrecht auf die Zone.**
- `stripe.secret` und `STRIPE_WEBHOOK_SECRET` — ohne: kein Auftrag entsteht überhaupt
- `monitoring.token` — Uptime-Kuma-Brücke, `RegisterMonitoring`
- `SECRETS_KEY` — **seit heute Nacht auch für `hosts.api_token_ref`.** Fehlt er, ist
  jeder Host-Token unlesbar und keine Bereitstellung läuft.
- `VPN_CONFIG_KEY`

**Einstellungen:**
- `CLUPILOT_DNS_ZONE` = **`clupilot.cloud`** (nicht `.com`). Vorgabe im Code ist
  richtig; auf diesem Server stand in der `.env` versehentlich `.com`.
- `CLUPILOT_WG_HUB_PUBKEY`, `CLUPILOT_WG_ENDPOINT`, `CLUPILOT_WG_SUBNET` — und der
  **Hub muss laufen und auf 51820/udp erreichbar sein**. Die Hosts sind Initiatoren;
  der offene UDP-Port ist eine Anforderung an den Hub, nicht an die Hosts.
- `CLUPILOT_DNS_HOSTS_DIR` — dnsmasq-Verzeichnis für die internen Hostnamen,
  geteiltes Volume mit dem `vpn-dns`-Container
- `TRAEFIK_DYNAMIC_PATH`
- **Firmendaten vollständig** (Name, Adresse, PLZ, Ort, UID) **und Steuersatz** —
  sonst verweigert `IssueInvoice`, und der Kunde bekommt keinen Beleg
- **Mindestens ein Rechenzentrum** angelegt — `ValidateHostInput` verlangt es
- **Postfächer** eingerichtet — sonst erreicht die Zugangsdaten-Mail den Kunden nicht

**Betrieb:**
- `queue`, `queue-provisioning`, `scheduler` und `reverb` laufen — die Pipeline ist
  warteschlangengetrieben, ohne Worker passiert schlicht nichts
- Stripe-Katalog nach dem Update: `stripe:sync-catalogue` **und**
  `stripe:reprice-subscriptions` (je zuerst `--dry-run`). Der Abgleich legt jetzt
  **zwei** Preise je Paket an; bis er lief, kann ein geprüfter EU-Firmenkunde nicht
  bestellen — das ist Absicht.
- `stripe:sweep-orphan-prices` listet jeden **aktiven wiederkehrenden**
  Stripe-Preis an unseren Produkten, den **keine** Zeile in
  `stripe_addon_prices` oder `stripe_plan_prices` kennt — mit ID, Betrag,
  Währung, Intervall und Metadaten. Einmalige Preise sieht es nicht; von uns
  angelegt ist nie einer davon, `createPrice()` schickt immer
  `recurring[interval]`.
  Ohne Argument wird nichts angefasst; mit `--archive` werden die aufgelisteten
  Preise bei Stripe stillgelegt, also **nicht mehr verkauft** (laufende Abos
  darauf verrechnet Stripe weiter). **Zuerst `stripe:sync-catalogue` laufen
  lassen:** das Kommando prüft nur, ob eine Zeile die ID kennt, nicht ob der
  Wiedererkennungsschritt die Waise übernommen hätte. Eine übernehmbare Waise
  vorher stillzulegen macht sie für den Abgleich unsichtbar (er fragt Stripe nur
  nach aktiven Preisen) — er fällt dann auf `createPrice()` mit demselben
  Idempotenz-Schlüssel zurück, den der abgebrochene Lauf benutzt hat, und Stripe
  antwortet 24 Stunden lang mit der gespeicherten Antwort: der ID des
  stillgelegten Preises. Danach steht im Register ein Preis, auf den Stripe
  keinen Checkout mehr öffnet.

---

## 5. Reihenfolge des ersten echten Durchlaufs

1. Testpaket „Test" in der Konsole anlegen (20 GB / 1 GB / 1 Kern / 5 GB) und veröffentlichen.
2. Abschnitt 4 durchgehen und **jedes** Stück bestätigen.
3. Debian 13 auf dem dedizierten Server, Host in der Konsole anlegen, onboarden.
   Erwartung nach Block B/C: läuft bis `active` durch. Ohne Block B bricht es bei
   `vmbr0` ab — das ist derzeit gewollt.
4. Vorlage bauen (Block A), `VerifyVmTemplate` muss sie finden.
5. Testpaket kaufen. Erwartung: 16 Schritte bis `CompleteProvisioning`, Nextcloud
   erreichbar unter `<subdomain>.clupilot.cloud` mit gültigem Zertifikat,
   Speicherkontingent greift, Zugangsdaten-Mail kommt an.
6. Eigene Domain hinterlegen, TXT-Nachweis, prüfen, dass sie **wirklich ausgeliefert**
   wird (nicht nur „nachgewiesen").
7. Kündigen. Prüfen, dass Stripe es erfährt, die Leistung zum Periodenende endet,
   Route und DNS-Eintrag verschwinden.
8. Zweiten Kauf als Privatperson, innerhalb 14 Tagen widerrufen. Prüfen: volle
   Erstattung, Stornorechnung, Leistung endet.

Jeder Schritt, der scheitert, ist ein Fund — und der Server darf dabei kaputtgehen,
Debian ist neu eingespielt in Minuten.

---

## 6. Arbeitsweise in dieser Codebasis (für die neue Session)

- **Eine zweite Claude-Session arbeitet im selben Arbeitsverzeichnis und im selben
  Git-Index.** In dieser Nacht hat ein einziges `git commit` ohne Pfadangabe deren
  vorgemerkte Dateien mitgenommen und dabei `VERSION` sowie zwei ihrer Dateien
  zurückgedreht. **Immer** `git add -- <pfade>` und `git commit -F - -- <pfade>`,
  **nie** `git add -A`, `git add .`, `git commit -a` oder ein nacktes `git commit`.
- `pint` nur auf eigene Pfade, **nie** `--dirty` — das Repo ist unter der
  Standardvorgabe nicht durchgängig formatiert und würde fremde Dateien umschreiben.
- Regeln R11–R24 in `CLAUDE.md` sind nicht verhandelbar und werden per Test erzwungen.
  R22 besonders: eine Prüfrunde, eine Fix-Runde, dann parken.
- Tests laufen auf **SQLite im Speicher** (`phpunit.xml` erzwingt es), gleichzeitige
  Läufe kollidieren also nicht, und die Entwicklungsdatenbank wird nie angefasst.
- Bricht die ganze Suite auf einmal zusammen, ist das fast immer eine Datei der
  anderen Session mitten im Schreiben — einmal neu laufen lassen, bevor man sucht.
- Push: `git push "https://x-access-token:$TOKEN@git.bave.dev/boban/CluPilotCloud.git" main`
  mit `GIT_ACCESS_TOKEN` aus der `.env`; ein einfaches `git push` scheitert.
- Ein Update auf dem Live-Server greift nur bei einem **`v*`-Tag** — `VERSION` anheben,
  taggen, Tag mitpushen.
