233 Commits (6314bb60fb7cfb4083e20e686f49cff27e4c0d84)
| Author | SHA1 | Message | Date |
|---|---|---|---|
|
|
6314bb60fb |
Schalter je Paket, ehrliche Statusanzeige, Reste der alten Leiter
Die Umschaltmigration verglich beim Erkennen eines bereits umgeschalteten Bestands nur Kontingent und Platte. Bei Intern waren beide schon vorher richtig (5/20 GB), also hielt sie das Paket für erledigt und ließ RAM, Kerne und Plätze auf ihren alten Werten stehen (1024 MB/1 Kern/5 statt 4096 MB/2/3). Die Prüfung vergleicht jetzt alle neun Vorgaben; eine zweite, kleine Migration hebt einen Bestand nach, auf dem die erste schon lief, und tut nichts auf einer Neuinstallation. Die Statusanzeige unterschied bisher nicht zwischen "kein Angebot" und "läuft, aber nicht im Laden" — ein internes Paket zeigte "Nichts verfügbar" in der Liste und "Im Verkauf" auf der Versionsseite darunter. Vier Zustände statt zwei, mit fester Reihenfolge: der Notausschalter (sales_enabled) schlägt die Konsolen-Kennzeichnung (internal). Ein zweiter Schalter je Paketfamilie nimmt sie aus dem Preisblatt, ohne sie unverkäuflich zu machen — nach dem Vorbild des vorhandenen Verkaufsschalters, ohne Bestätigungsmodal, weil reversibel. Enterprise wechselt von sales_enabled=false (weder käuflich noch verschenkbar) auf internal=true, wie das Testpaket. Dazu die liegengebliebenen Zahlen der alten Leiter in Produktattrappe, Mail-Vorschau, Fabrik-Vorgaben und Seedern, sowie eine Testzusicherung, die auf eine wandernde ID statt auf den berechneten Wert hätte treffen können. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
4ad1828f27 |
Ein reservierter Host gehoert seinem Kunden
placeableIn() nahm jeden aktiven Host im Rechenzentrum, und hasRoomFor() sowie largestPlaceableGb() zaehlten eine exklusiv verkaufte Maschine obendrein mit — der Shop versprach damit Platz, der bereits vergeben war. Ohne diese Markierung ist 'eigener Server' ein Satz im Angebot und nirgends eine Tatsache. hosts.reserved_for_customer_id (nullable, ueberlebt den Kunden) markiert die Maschine; Host::placeableIn() und HostCapacity zaehlen sie nur noch fuer den eigenen Mieter oder gar nicht mehr zum allgemeinen Bestand. Auf der Host-Detailseite kann ein Operator reservieren und wieder loesen — Loesen laeuft ueber ein eigenes Bestaetigungsmodal (R23), das selbst nichts aendert, sondern nur an HostDetail::releaseReservation() zurueckmeldet. Host-Liste und Kapazitaetsseite weisen eine reservierte Maschine als solche aus, statt sie kommentarlos verschwinden zu lassen. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
c3c05ff9f8 |
Interne Pakete verschwinden aus dem Laden und bleiben in der Konsole
sales_enabled waere der falsche Hebel gewesen: es schaltet auch das Verschenken ab. Das Testpaket ist fuer Abnahmelaeufe da und muss verschenkbar bleiben, also zwei Felder fuer zwei Fragen. Der Checkout lehnt einen internen Schluessel ausdruecklich ab, ueber denselben Fang wie einen unbekannten — eine URL ist keine Liste, und die Antwort darf keinen Unterschied verraten. GrantPlan las bislang dieselbe sellable()-Liste wie Preisblatt und Warenkorb, sowohl fuer sein Dropdown als auch fuer die Validierung des Formularfelds — ein interner Schluessel waere dort ebenso abgelehnt worden wie im Checkout, und das Verschenken haette sein einziges Tor verloren. PlanCatalogue bekommt deshalb grantable() als zweiten Leser derselben Abfrage. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
afa6183903 |
Zusatzspeicher: 20 GB fuer 15 Euro, hoechstens drei, nicht bei Enterprise
100 GB fuer 10 Euro waren 0,10 Euro/GB — unter Einstandspreis und billiger als jeder Aufstieg. Wer stapelt, belegt dann den knappsten Rohstoff zum niedrigsten Preis. 0,75 Euro/GB liegen ueber beiden Aufstiegen (0,73 und 0,67), und der Deckel bei drei liegt dort, wo Aufsteigen billiger UND besser wird. BookAddon haelt den Ausschluss durch AddonCatalogue::availabilityRefusal() (gleiches Muster wie CustomDomainAccess), damit greift er auch beim Verschenken durch den Betreiber. Billing::purchase() und storageLimitNote fragen dieselbe Regel VOR der Zahlung, sonst haette ein Enterprise-Kunde einen zahlbaren Auftrag anlegen koennen, den BookAddon erst danach abgelehnt haette. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
11ba7ee34f | Hostdetailseite: die Adresse steht neben den IPs und ist ein Klick zur Proxmox-Oberflaeche | |
|
|
c6ead1afc9 |
Deckel haelt auch am Rand: Warenkorb und Bestaetigungsdialog
Zwei Luecken aus der Durchsicht von Task 2, beide am selben Rand: genau am erreichten Deckel. purchase() klemmte $lines mit max(1, min(..., bookableQuantity, ...)) — bei bookableQuantity=0 floort das auf eine Bestellzeile statt auf keine, und der Speicher-Knopf war bedingungslos gerendert. Jetzt bricht purchase() fuer type=storage frueh ab, wenn AddonCatalogue::quantityRefusal() ablehnt, und der Knopf verschwindet zugunsten des Ablehnungssatzes. ConfirmBookStorage klemmte an der absoluten Grenze (3), nicht an der Restmenge — ein Vertrag mit einem gebuchten Block haette drei weitere versprochen bekommen, obwohl nur zwei noch buchbar sind. Das Modal loest jetzt seinen eigenen Vertrag auf (wie ConfirmCancelAddon und ConfirmRevokeSeat) und klemmt an bookableQuantity(). Beide Faelle waren ungetestet; zwei neue Tests in StoragePackLimitTest.php belegen sie und wurden vor dem Fix gegen den alten Stand als rot verifiziert. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
d62a2c8ff8 |
Hostnamen vergibt CluPilot: ein Name statt zweier, und der Zaehler ueberlebt das Loeschen
HostName::claim() haengt den Namen jetzt an die Rechenzentrums-Zeile
(next_host_number), nicht an MAX(hosts.name)+1: der Zaehler ueberlebt so
das Loeschen des zuletzt angelegten Hosts. hosts.dns_name faellt weg -
name ist ab jetzt der einzige Name, den Konsole, DNS, /etc/hosts und
Proxmox-Node teilen. RegisterHostDns veroeffentlicht nur noch, was
StartHostOnboarding beim Anlegen vergeben hat, statt selbst zu
nummerieren; PrepareBaseSystem baut den FQDN ueber HostName::fqdn()
statt ueber die nirgends konfigurierte clupilot.net.
Zwei Testdateien ausserhalb der Aufgabenliste (DatacenterTest,
HostTakeoverPageTest) setzten ->set('name', ...) auf HostCreate, das
Feld jetzt aber nicht mehr hat - im vollen Testlauf nachgezogen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
1f32a2d5b3 |
Hoechstens drei Bloecke, geprueft in der Buchung
Die Ansicht durfte 50 in den Warenkorb legen, die Aktion pruefte nichts. Der Deckel liegt kaufmaennisch dort, wo Aufsteigen billiger wird als Stapeln, und gehoert deshalb dorthin, wo gebucht wird — gezaehlt ueber alle laufenden Buchungen, denn drei Bestellungen a einem Block sind drei Bloecke. StorageAllowanceTest schrieb die alte Notbremse (50) als erwartete Zahl fest; angepasst auf die jetzt engere kaufmaennische Grenze (3), wie im Aufgabenblatt fuer DowngradeTest vorgezeichnet. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
55b749c129 |
Release v1.3.94 — die Kurven zeigen wieder, wie ausgelastet der Host ist
tests / pest (push) Failing after 9m19s
Details
tests / assets (push) Successful in 21s
Details
tests / release (push) Has been skipped
Details
Drei Punkte des Besitzers, und der dritte hat den eigentlichen Fehler
freigelegt.
Die Verlaufslinien sahen komisch aus, weil sie auf ihr EIGENES Minimum und
Maximum skalierten. Ein Host mit 0,2 % CPU, der zwischen 0,1 und 0,3 schwankt,
zeichnete damit einen Seismographen über die volle Höhe — und widersprach flach
der Zahl direkt daneben. Dieselbe Regel stand im Chart.js-Entwurf schon
ausformuliert ("eine automatisch skalierte Achse lässt 3 % wie eine Wand
aussehen") und ging beim Umstieg auf die Kacheln verloren.
x-ui.spark bekommt deshalb `min`/`max`. Ohne Angabe bleibt alles wie bisher —
das ist, was jeder bestehende Aufrufer übergibt. Die Prozent-Kacheln geben
0 und 100 mit, die Netz-Kacheln nur die 0, weil MiB/s keine natürliche
Obergrenze haben. Ein untätiger Host zeichnet jetzt vier ruhige Linien statt
zweier wogender und zweier flacher — vorher wirkte CPU (0,2 %) belebter als RAM
(3,6 %), also genau verkehrt herum.
Dazu: Werte außerhalb der Grenzen werden geklemmt statt aus dem Kasten
gezeichnet, die Fläche ist ein Verlauf statt einer harten Kante, und die
Linien sind mit 120×40 statt 80×32 lesbar.
Ich hatte max=100 zwischendurch selbst verworfen, weil es "zu tot" aussah — auf
einem Bild, auf dem die Füllung wegen eines CSS-Fehlers gar nicht gezeichnet
wurde. Ein Vergleich mit einem kaputten Bild. Der Fehler: `.spark path
{ fill: none }` schlägt als CSS-Regel das Präsentationsattribut fill="url(#…)".
`fill: none` gehört an die Linie, nicht an jeden Pfad.
Die Ausstattung ist wieder einzeilig. Sechs Felder, sechs Spalten — und die
Bau-Kennung steht nur noch im Titel: als eigene Zeile zwang sie die ganze Tafel
in eine zweite Reihe, für eine Zeichenkette, die fast niemand liest.
Und die Wartezeit: die Seite stößt beim Öffnen eine Sammlung an, wenn noch
keine Messwerte da sind, statt bis zum nächsten minütlichen Lauf leer zu
bleiben. Ein leerer Kasten liest sich als "kaputt", nicht als "gleich". Der Job
ist ShouldBeUnique, zwei geöffnete Seiten reihen also keine zwei ein.
Codex-Befund dazu (P2): das galt auch für Hosts, die der Sammler ohnehin
überspringt — ein Host mitten in der Übernahme hat keinen Token, und seine
Seite hätte die ganze Flotte abgeklappert und die eigenen Kacheln trotzdem leer
gelassen. Wer gesammelt wird, steht jetzt einmal am Modell (Host::collectable),
gelesen von Job und Seite. Zwei Fassungen dieser Frage waren genau der Grund.
Geprüft: 2307 Tests grün, Pint sauber, Codex ohne Befund. Der Test für den
Maßstab prüft jetzt auch die echten Aufrufstellen und nicht nur das Bauteil —
gegengeprobt durch Entfernen von max=100, dann fällt er. Im Browser beide
Fälle angesehen: ruhiger Host vier flache Linien, ausgelasteter Host lesbare
Form, null Konsolenfehler.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
589489a9fc |
Release v1.3.92 — sechs Kacheln statt dreier ungleicher Kästen
tests / pest (push) Failing after 10m15s
Details
tests / assets (push) Successful in 28s
Details
tests / release (push) Has been skipped
Details
Nach der Vorlage des Besitzers. Zustand, Speicher und die gemeinsame Kurve standen nebeneinander: zwei davon halb leer, die dritte überfüllt, und die gemeinsame Kurve brauchte eine Legende, um zu sagen, welche Linie welche ist. Eine Reihe je Kachel löst alle drei Beschwerden auf einmal. Die Beschriftung der Kachel benennt die Reihe, also braucht es keine Legende mehr. Die Höhen sind durch das Raster gleich statt zufällig. Und der leere Platz ist mit Messwerten gefüllt, die es ohnehin schon gab: Proxmox' Aufzeichnung liefert Netzdurchsatz in beide Richtungen mit, ungefragt. Sechs Kacheln: CPU-Auslastung, RAM-Auslastung, Speicher (als Ring), Eingehend, Ausgehend, Zustand. Gebaut mit x-ui.metric, x-ui.spark und x-ui.ring — die gab es alle schon, und der Kopfkommentar von x-ui.metric sagt selbst "exactly as the approved template draws it". Nichts daneben neu gebaut. Die öffentliche IP steht jetzt auf der Seite. Sie stand vorher NUR klein unter der Überschrift — die Adresse, unter der der Host wirklich erreichbar ist, war in der Detailseite nirgends ein Feld. Sie führt jetzt die Ausstattungs-Tafel an, und die Reserve-Eingabe ist mit dorthin gezogen: die Kacheln zeigen, was gemessen wurde, die Tafel, was eingestellt ist. Ein Eingabefeld zwischen Messwerten sähe aus, als ließe sich eine Messung ändern. Alle vier Verlaufslinien tragen denselben Ton. Die Regel steht im Bauteil selbst — "muted where the figure is observed, accent where it can be acted on" —, und hier ist keine Zahl anzufassen. Vier verschiedene Töne nebeneinander behaupten einen Unterschied, den es nicht gibt. Zwei Funde aus der Prüfung -------------------------- - x-ui.spark warf fehlende Messwerte per array_filter heraus und verband die Nachbarn. Zwei Fehler auf einmal: die Linie behauptete eine Messung, die es nicht gab, und alles danach rutschte nach links — eine Stunde mit zwei Lücken zeichnete sich als achtundfünfzig Minuten. Die x-Lage kommt jetzt aus dem Platz in der URSPRÜNGLICHEN Reihe, und zusammenhängende Messwerte werden als eigene Züge gezeichnet. Eine saubere Reihe ergibt genau einen Zug und dasselbe Bild wie vorher, was alle bisherigen Aufrufer liefern. - Der Zwischenspeicher überlebt einen Deploy. Ein Eintrag aus v1.3.91 kennt netin/netout nicht, und die Host-Seite wäre 55 Sekunden lang an einem fehlenden Schlüssel gestorben — genau in der Minute, in der jemand nachsieht, ob das Update durch ist. Der Schlüssel heißt jetzt host-load:v2:<id> und wandert mit der Form mit. Und einer, den kein Prüfer gemeldet hat: beim Zerlegen in Züge stand im Flächenpfad ein `L` unmittelbar vor einem `M`. Gültig gelesen, nicht gezeichnet — die Füllung verschwand still. Aufgefallen ist es beim Ansehen der Seite, nicht durch eine Meldung; jetzt prüft ein Test, dass jeder Flächenpfad mit M anfängt, mit Z endet und keinen Befehl direkt hinter einem anderen trägt. x-ui.chart behält seinen update-on-Weg, obwohl diese Seite ihn nicht mehr benutzt: er ist eine geprüfte Fähigkeit des gemeinsamen Bauteils, und der darunterliegende Fix (Instanz aus dem reaktiven Alpine-Objekt) gilt für jeden Chart. Geprüft: 2291 Tests grün, Pint sauber, Codex ohne Befund. Im Browser mit eingespielten Messwerten: sechs Kacheln, Lücke als echte Aussparung in Linie UND Fläche, Leerzustand zeigt "—" statt einer Null, null Konsolenfehler über einen vollen Poll-Zyklus. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
665a0c4e08 |
Release v1.3.91 — die Host-Seite zeigt Last statt Ausstattung
tests / pest (push) Failing after 9m29s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Die Karte "Rechenleistung" zeigte keine Leistung. 12 Kerne und 63 GB sind die
Ausstattung des Blechs und ändern sich nie — sie standen aber im selben
Kartenraster wie "Zustand" und "Speicher", die beide leben. Wer die Seite
öffnete, um zu sehen, wie es dem Host geht, las dort eine Zahl, die das nie
sagen konnte.
An ihrer Stelle steht jetzt die Last: CPU und RAM als Stundenkurve, beide in
Prozent auf EINER Achse, dazu die aktuellen Werte als beschriftete Zahlen.
Die Geschichte kommt aus Proxmox' eigener Aufzeichnung
(/nodes/{node}/rrddata), nicht aus einem eigenen Sampler. Ein Sampler hieße
neue Tabelle, minütlicher Job, Aufräum-Job und ~1440 Zeilen je Host und Tag —
um weniger genau nachzubauen, was ohnehin auf der Platte liegt. Die RRD ist ab
der ersten Sekunde gefüllt, auch für die Stunde vor dem ersten Hinsehen, und
kann nicht von dem abweichen, was Proxmox' eigene Oberfläche zeigt.
Eine Lücke bleibt eine Lücke: ein Punkt ohne Messwert wird null, nie 0, und
spanGaps steht auf false. Dieselbe Regel wie in instance_metrics. Antwortet der
Host gar nicht, sagt die Tafel das in einem Satz, statt eine ruhige Stunde zu
zeichnen — auf dem Testhost live bestätigt.
Der Fehler, den das ans Licht gebracht hat
------------------------------------------
x-ui.chart steht überall unter wire:ignore, sonst zerstört Livewire das Canvas.
Ein Poll erreicht den Chart also nie. Dafür bekam das Bauteil ein optionales
update-on: es hört auf ein Fenster-Ereignis und tauscht die Daten IM
bestehenden Chart.js-Objekt.
Das lief nicht. Die Zahlen neben der Kurve wanderten, die Kurve nicht, und
chart.update() starb still im Legenden-Plugin:
TypeError: Cannot set properties of undefined (setting 'fullSize')
Grund: die Chart.js-Instanz lag als Eigenschaft im Alpine-Objekt und wurde
damit reaktiv umhüllt. Chart.js' Plugin-Innenleben überlebt das Proxy nicht.
Gemessen statt geschlossen: dieselbe Instanz wirft über das Proxy und läuft
über Alpine.raw(). Sie liegt jetzt in der Closure.
Aufgefallen ist es nie, weil bis zum ersten Live-Chart kein einziger Chart in
diesem Repo je update() gerufen hat — konstruieren und Erstzeichnen gehen durch
die Hülle noch. tests/Feature/ChartLiveUpdateTest.php hält die Regel fest,
damit der nächste Live-Chart nicht denselben Nachmittag kostet.
Der Rest
--------
- Version lesbar: "Proxmox VE 9.2.6" statt pve-manager/9.2.6/7f8d…, mitten in
der Bau-Kennung abgeschnitten. Die Kennung steht klein darunter. Eine
unerwartete Form wird unverändert durchgereicht statt verschluckt.
- Vier Kleinkarten (Mgmt-IP, Node, Version, Instanzen) sind eine
Ausstattungs-Tafel geworden. Die Instanzen-Anzahl steht in der Überschrift
der Liste, die sie ohnehin zeigt.
- Der Übernahme-Fortschritt klappt zu, sobald sie durch ist. Fünfzehn
abgehakte Schritte sind auf einem laufenden Host kein Dauerinhalt —
aufklappbar über <details>, ohne JavaScript.
- PlanVersion::requiredTemplateVmids() ersetzt die dritte Kopie derselben
Fensterlogik.
- BuildVmTemplate sagt nicht mehr "this takes 10–20 minutes". Das war eine
Schätzung vor dem ersten Lauf; gemessen waren es unter zwei. Eine Konsole,
die falsch vorhersagt, erzieht dazu, sie zu ignorieren.
Geprüft: 2281 Tests grün, Pint sauber, Codex ohne Befund. Die Farbwahl gegen
den Validator gerechnet (ΔE 28,3 protan / 39,2 normal; der Akzent liegt unter
3:1 gegen die Fläche, deshalb tragen beide Reihen sichtbare Beschriftung). Im
Browser: null Konsolenfehler über einen vollen Poll-Zyklus, und die Kurve
wandert auf dem echten Poll ohne Neuladen — mit eingespielten Messwerten
belegt, weil die Testhosts in TEST-NET liegen und nie antworten.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
2f15d1e7a4 |
Release v1.3.84 — VPN-Zugänge nach Personen und Hosts getrennt
tests / pest (push) Failing after 9m6s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Personen und Hosts standen gemischt untereinander, nach Verbindungszustand sortiert — ein Host-Zugang konnte also zwischen zwei Mitarbeitern stehen. Beide sind Peers im selben Netz und haben dieselben Messwerte, aber es sind zwei verschiedene Fragen: "wer von uns ist im Netz" und "welche Maschinen hängen dran". Wer die eine stellt, liest die Antworten der anderen als Rauschen. Jetzt zwei Gruppen mit Überschrift und Anzahl. Eine Gruppe ohne Einträge wird gar nicht erst gezeichnet — auf einer frischen Installation stünde sonst "Hosts" über einer Lücke, bevor je einer angelegt wurde. Getrennt wird nach der geladenen host-Beziehung, nicht nach `kind`: die Plakette in der Zeile fragt dasselbe, und wer einen "Host-Zugang" liest, soll ihn auch unter den Hosts finden. Ein adoptierter Peer (kind=system) mit host_id gehört zu den Hosts, obwohl seine Art etwas anderes sagt. Die Zeile selbst ist nach resources/views/components/admin/vpn-peer-row.blade.php gewandert. Sie zweimal hinzuschreiben hätte geheissen, sie ab dem nächsten Knopf an zwei Stellen zu pflegen — und die zweite fällt erst auf, wenn jemand einen Host-Zugang sucht und ihn anders aussehen sieht als seinen eigenen. Die Plakette bleibt trotz der Überschrift daneben: eine Zeile wandert beim Suchen aus ihrer Überschrift heraus, und dann steht sie ohne sie da. Codex: keine Befunde. 2237 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ac81bc5b98 |
Release v1.3.82 — Übernahme-Befehlszeile neu ausstellen
tests / pest (push) Failing after 8m50s
Details
tests / assets (push) Successful in 23s
Details
tests / release (push) Has been skipped
Details
Die Zeile gab es genau einmal: auf der Seite direkt nach dem Anlegen. Wer sie dort nicht kopierte — oder wem sie wegbrach, wie beim WireGuard-Fehler bis 1.3.80 — hatte danach einen Host in der Liste, für den es keinen Weg zu einer Befehlszeile mehr gab. Ein zweites Anlegen scheitert an der eindeutigen IP, also blieb nur Entfernen und von vorn. HostTakeoverCommand behauptet in seinem Kopfkommentar, die Zeile werde "an zwei Stellen gezeigt (beim Anlegen und beim Neuausstellen eines Codes)". Das Zweite gab es nie — ein Kommentar, der eine Absicht beschreibt und wie eine Beschreibung des Gebauten klingt. Neu: ReissueTakeover als Modal auf der Host-Detailseite. Bestätigen zuerst (R23), denn ein bisher ausgegebener Code wird dabei wertlos; danach steht die ganze Zeile mit Kopieren-Knopf darin, nicht nur der Code. Codex, zwei Runden, beide Male dieselbe Sorte Fehler von mir — die Ansicht versteckt den Knopf, die Methode prüft nichts: - P1: `issue()` erzwingt jetzt selbst, welcher Zustand zulässig ist. Ein Modal, das offen blieb, während der Host aktiv wurde, rief die Methode trotzdem; eine Livewire-Methode ist ohnehin von aussen aufrufbar. - P2: `issue()` prüft die Tunnel-Einstellungen erneut. Sonst wird ein gültiger Code entwertet und dafür eine Zeile ausgegeben, der die WireGuard-Angaben fehlen — genau die kaputte Zeile, vor der die Warnung daneben steht. - P1 der zweiten Runde: `disabled` gehörte nicht in die Liste der zulässigen Zustände. Es sieht aus wie "noch nicht fertig" und ist das Gegenteil — toggleMaintenance() schaltet einen LAUFENDEN Host so still. Der Knopf hätte eine Produktionsmaschine zur Neuinstallation angeboten. Die Bedingung steht deshalb einmal am Bauteil (ReissueTakeover::eligible) und wird von Ansicht und Methode gefragt: zwei Fassungen liefen auseinander, sobald jemand eine ändert. 2231 Tests grün. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
4f037c8264 | Mahnwesen: Einstellungen und die zwei Eingriffe von Hand | |
|
|
fb1ef3fc63 | Rollen: aufklappbare Karten, gruppierte Rechte, Knopf im Raster | |
|
|
29c2718235 | R20: Begruendung im Modal, und der Waechter sieht jetzt auch Listenzeilen | |
|
|
7f7b654fcb | Zahlungsprobleme: geplatzte Kaeufe aufzeichnen, beide Arten an einer Stelle | |
|
|
93e848d3f9 | Rollen und Rechte in der Konsole zuweisbar | |
|
|
6a6a672c11 | Pruefergebnis als Modal statt am Seitenende | |
|
|
f91726d078 | Testergebnis erscheint am Feld statt am Seitenende; DNS-Token bekommt einen Testknopf | |
|
|
b8a774b604 | Portal: Karte tauschen, offene Rechnungen begleichen, Hinweis bei Faelligkeit | |
|
|
0944de7cfa | Passwortrichtlinie einstellbar, doppelte Anmelde-Mail behoben | |
|
|
200df30b9d |
Hetzner-Cloud-DNS, Bereitschaftsseite aus sich heraus behebbar
Die alte Hetzner-DNS-API ist abgeschaltet (301 auf die Weboberflaeche).
Jede Bereitstellung starb in ConfigureDnsAndTls, nachdem der Kunde bezahlt
hatte. HttpHetznerDnsClient und DnsTokenCheck sprechen jetzt die Cloud-API:
Bearer statt Auth-API-Token, RRSets ueber {name}/{typ} statt Record-IDs, der
Zonen-Lookup entfaellt. Gegen das Live-Konto gemessen, nicht geraten.
Drei Fallen, die am Konto gemessen wurden: TXT-Werte muessen in
Anfuehrungszeichen (sonst 422, was als read_only gemeldet worden waere), ein
Name mit Zonensuffix wird STILL angenommen (201), und der Fake gab andere IDs
aus als der echte Client -- zwoelf Tests waren gruen ueber einem Abbau, der im
Betrieb geworfen haette.
Bereitschaftspunkte, die nur eine Shell beheben konnte, sind jetzt bedienbar:
SSH-Schluesselpaar erzeugen (Ed25519 ueber phpseclib, privater Teil direkt in
den Tresor), Stripe-Katalog abgleichen (Warteschlange, Trockenlauf, Ausgabe
wortgleich), Mailzustellung als Schalter statt MAIL_MAILER, Neustart der
Arbeiterprozesse. Stripe hat jetzt alle drei Werte in der Konsole: Secret Key,
Signatur-Secret (Tresor, je Modus getrennt) und Publishable Key (Klartext).
Codex-Review (R15), zwei P1 behoben: der Signaturschluessel faellt nicht mehr
vom Live- in den Testplatz, und eine Record-ID aus der alten API macht einen
Host nicht mehr unloeschbar -- was adressierbar ist, wandelt eine Migration um,
der Rest wird beim Loeschen laut uebergangen statt geworfen.
2109 Tests gruen.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
cfaea7dc82 |
Fill the hostname page from the installation, not from a blank form
The page was empty, and that was a design fault rather than a missing button. I built a register that starts blank — while the installation already serves half a dozen names whose certificates were exactly what the operator wanted to see. An overview you have to populate first does not answer "what do I have". So the names are derived now, from the same configuration routes/web.php builds its domain bindings from: SITE_HOSTS, APP_HOST, FILES_HOST, ADMIN_HOSTS. If a name is in the environment, the application answers on it, and then it belongs in this list without anybody typing it a second time. Opening the page syncs them; the list is never empty again. Syncing and measuring are deliberately separate. The sync costs nothing and runs on page load. The measurement goes out over the network and runs on the button or on a schedule — doing it on page load would mean waiting through half a dozen TLS handshakes, and one of them is always the name that currently does not resolve. Which is the other half of what was missing: there was no overview because nothing measured unless somebody pressed a button. A daily run at 04:17 fills it, because the question that matters is not "is it valid right now" but "is renewal running" — a certificate expiring in forty days is fine, the same one at twenty means something has been broken for a week. Only a measurement taken while nobody is looking can tell those apart. The page now opens with four counts — total, valid, expiring soon, without a certificate — and says when it last measured. A name that comes from the environment is marked as such and cannot be removed here: it would come back at the next sync, and a button that does nothing is worse than no button, because it gets believed once. 2040 tests pass, assets build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
006ce3568b |
Manage hostnames and watch their certificates from the console
files.clupilot.com is why this exists. DNS pointed at the right machine, the env var was set, the release was deployed — and there was still no certificate, because /etc/caddy/Caddyfile is maintained by hand and nobody thought of it as a second, separate step. Nothing in the console would have said so. Three settings looked correct and the address did not answer. So the page holds two things side by side. The WISH — which names should be served — and the REALITY: whether the name has a certificate and for how much longer. The second is measured by opening a TLS connection and reading the expiry, not by reading configuration, because the configuration is exactly what looked right while the address was dead. verify_peer stays on: a certificate that fails validation is not a certificate for this question, and a display that called it valid would be the fake R19 records. Applying goes through the existing agent, not a new channel. The console writes a request, the path unit wakes the agent within a second, and the agent calls one fixed command line of the root-owned helper. What that helper is allowed to do is the careful part. It fetches the list ITSELF rather than being handed one, and the list is HOSTNAMES, never Caddy blocks — `php artisan clupilot:proxy-hosts` prints `<name> <purpose>` and nothing else. Each name is matched against a strict pattern before it is used, and the template around it lives in the helper, which the service account cannot touch. install-agent.sh already states the principle for the sudoers grant: a grant is only worth anything if the holder cannot change what it grants. A service account that could write proxy configuration would have everything the proxy can do — redirects anywhere, files from any directory. Purpose is a column rather than a habit. A console name gets the network lock, a public one does not, and a console name published without it looks exactly like a working page. Removing takes the name out of the list and NOT out of the running proxy. Two decisions in one click, and the second one takes a site off the air. There is deliberately no "renew" button. Caddy renews on its own at two thirds of the lifetime; what an operator actually needs is a second attempt after an issuance has failed, and that is a reload — which is what Apply does. Thirty days is treated as a problem rather than a warning: at ninety days' lifetime, a renewal should long since have run, so anything under it is not a tight certificate but a renewal that is not happening. The ACME contact falls back to the owner's address, because a contact nobody reads is the step before expired customer certificates. CONTRACT and HOST_STEP_NEEDS both move to 2, which is what tells a server carrying the older helper to run the installer again — caught by the guard test that compares the two halves. 2035 tests pass, assets build. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
2194ab8079 |
Put the instructions where somebody reads them before they start
The previous commit shipped the guide AFTER the host was created. Its first two steps happen at the provider — order the machine, boot the rescue system — and whoever reads them there has the one-time code already in the clipboard with its 24-hour window running. The instructions were correct and in the wrong place, which is its own kind of wrong. Six steps now, not three, and the two provider ones are named rather than assumed. They are marked "first" so the list does not read as something you can start at the top of and work down: step 2 is half an hour of waiting, and doing it inside a running code is exactly the mistake the page should prevent. All six are visible the whole time, including the done ones and the ones still to come. A guide that shows only the current step cannot answer "how much is left", which is the question somebody has while a server they are paying for sits in a rescue system. It is on the hosts list too, collapsed. Somebody standing there may not have ordered the machine yet — and that is step 1. Requiring them to click "add host" to find out what the procedure is puts the answer behind the action it describes. One component, two places, so the archive address in the instructions is the same string the command line will carry. Two copies of that would drift, and the difference would surface on a server that has already been paid for. The step-4 command box moved into the guide rather than sitting beside it, so after creating a host the whole procedure stays on screen. Somebody stuck at step 5 should not be looking at a page that has become a single box. 1991 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
903ebdd2b2 |
Give the operator one line to copy and three steps around it
The console could describe a takeover it had no way to start. This is the vertical slice that closes that: a one-time code, the archive the rescue system fetches, and the page that says what to do with both. The command carries EVERYTHING the script needs before the tunnel exists, because there is nothing to fetch — that is the whole point of spec §5. Which means CluPilot generates the WireGuard keypair and admits the peer at the hub before the machine has ever booted, and hands the private half over in the line. It is worthless within minutes: task 9 of the script replaces it with one generated on the machine. Shown exactly once. The database holds only the code's hash and never the private key, so leaving the page does not bring it back — it mints a new code, which invalidates the old one. That is deliberate: a glance at somebody's screen should be worth nothing an hour later. Which is also why save() no longer redirects. Sending the operator to the host detail page sends them away from the only value they need, and an existing test asserted that redirect — it now asserts the opposite, with the reason written next to it. SHA-256 rather than bcrypt for the code, and the reason is not speed. Both endpoints have to FIND the host by the code; with bcrypt that means trying every row. The code is 32 characters of CSPRNG output, so it has the entropy that stretching exists to manufacture. resolve() and claim() are separate because progress reports arrive BEFORE registration. If reporting consumed the code, a host could never register after its first message. The archive URL is always the public hostname. The console runs under admin.…, but this line executes on a machine that must not reach the admin area — it is locked down for exactly that reason — so route() from the console would emit a hostname that 404s on a server only reachable through the provider's console. The page warns about missing tunnel settings BEFORE the host is created, not after. An empty hub key produces a line that looks clean, copies fine, runs, and ends in a tunnel that never handshakes — discovered on the machine, after somebody has already paid for it. The three steps lead with the rescue system, because that is the one nobody knows by heart, and it says enabling is not the same as booting into it — the script refuses a running production machine, which is what a half-done switch looks like from the inside. 1986 tests pass. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
880b5f1998 |
Merge main into the operating-mode branch
Three real conflicts, and one file that did not conflict and mattered more.
Overview::notices(): both sides added notices. Kept all of them, and pointed
main's mail check at MailboxTransport::NON_DELIVERING — its own comment already
named the constant while the code carried a copy of the list.
billing.blade.php: main wrapped the page in a contract branch. The "payment is
not set up" error moved OUTSIDE it, because the customer most likely to meet
that message is the one buying for the first time, who has no contract yet and
would never have seen it from inside the @else.
ConsoleReportsRealDataTest: kept both sides rather than choosing. The renamed
test and its docblock explain why admin.systems_ok can no longer be asserted on
a bare install; main's mail pin still keeps "clean" clean in the mail
dimension. Picking one would have quietly weakened the other's claim.
HostStepsTest: git combined main's config()->set('provisioning.dns.zone',
'clupilot.com') with this branch's assertion on clupilot.cloud, and produced a
test that contradicted itself with no marker. Main's approach is the better one
— it pins the zone in the test and asserts the SHAPE of the name rather than
this box's domain — so its assertion stands.
HttpStripeClient merged silently and correctly: secret() still throws,
isConfigured() still reads the vault directly. Had it taken main's
filled($this->secret()), six callers that ask in order NOT to get an exception
would have become exception throwers, and the suite would have stayed green.
CheckoutWithoutStripeKeyTest is the lock that would have caught it.
StripeIdempotencyKeyTest (new on main) leaned on the environment fallback for
stripe.secret. That entry is strict now — no fallback in either direction, so
the .env cannot be a back door for a live key in test mode. It stores a real
vault row instead.
1966 tests pass.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
52aacddc56 |
Store the agreement where the web process can actually read it
Every link on the agreement page answered 404 — the download visibly, the two inline ones just as dead — and nothing anywhere said why. The files were there, whole and correct. An artisan run inside a container is root; PHP-FPM is www-data. Laravel's local disk creates a PRIVATE directory, which is 0700, so `dpa/` came out as root-only. The web process could not enter it, `Storage::exists()` answered false, and the routes did exactly what they were told: abort 404. No exception, no log line, a page full of links to nothing. Both writers — the command and the console's upload form — now store with "public" visibility, which is the file MODE and nothing to do with the web: 0755/0644 instead of 0700/0600. The disk's root is outside the document root either way, and the two routes still check who is asking. The command also warns if a file it just wrote is not readable, because the alternative is finding out from a customer. The download itself now looks like one: the `download` attribute on both anchors, and in the console a bordered control rather than a third link in a row of two. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
955f1b874f |
Deliver the processing agreement, and hold the proof it was accepted
Art. 28(3) GDPR wants a contract wherever personal data is processed on somebody else's behalf, which is the whole of what this product does. "In writing" there includes electronic form (Art. 28(9)), so a document the customer can read plus a recorded acceptance is enough — no signature on paper. The website already promises "AV-Vertrag inklusive", which means it has to be obtainable without asking us for it. It was not obtainable at all. **The text is never this application's.** An operator uploads the document their lawyer wrote, names the version, and publishes it; the measures ride along as a second file, because they are an annex to the agreement and "which measures applied when this customer accepted" has to have one answer. Inventing the text here would have been worse than having none. **Uploading and publishing are two acts.** Acceptance is per version, so publishing leaves every customer who accepted the previous one outstanding again — correct, and far too expensive to trigger by dropping a file on a form. It goes through a confirmation modal that says exactly that (R23). **The customer's side** is a card in the contract tab: read the agreement, read the measures, one press to conclude it. What that press records is what makes it evidence rather than a flag — the version, the moment, the address it came from, and the login that pressed. Pressing twice is one agreement (unique index, not a check somebody can forget), and a superseded acceptance is kept rather than overwritten: it was true when it was made, and the history is the point. Nothing renders until a version is in force. A card offering an agreement that does not exist is worse than the silence. The files live on the private disk and are served through routes that check who is asking — an agreement is not a public asset, and a guessable URL to one would be a list of who our customers are. The customer route takes no version parameter: which document applies is ours to say. `dpa.manage` is its own capability on the OPERATOR guard. Whoever keeps the platform running does not thereby decide what every customer is asked to agree to — and a capability written under `web` since the 2026-07-29 move lands in a guard nothing authenticates against, which is how this one first shipped answering 403 to a role that visibly had it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
1056dddc62 |
Take the address by field, fix the customer type, ask why they leave
**The billing address was one textarea**, so what landed in it was whatever somebody typed: a postcode on the street line, a city with no postcode, no country at all. It is four fields now — street, postcode, town, country — in the customer's settings and in the console's customer editor, which had its own free-text copy of the same field and would have had an operator's correction silently reverted the next time the customer saved. `customers.billing_address` is neither dropped nor parsed: guessing which line of an existing block is the street would put a postcode where a street belongs on a document nobody can correct afterwards. It becomes the COMPOSED form, rewritten from the fields on every save, so IssueInvoice and everything else that reads it keep working, and a record nobody has re-saved keeps the block it always had. **The customer type is fixed once it is on record.** It decides the fourteen-day right of withdrawal, and a business able to set itself to "Privatperson" on this page is a business able to withdraw from a contract it may not withdraw from. Registration asks the question; this page reports the answer, and saveProfile refuses to answer it a second time — in the component, not by hiding a radio, because a form that hides a control has never stopped anybody who can post to /livewire/update. A record created before the question existed may still be given one, once. The panel is a fact rather than a form now, which is also what was odd about it, and the sentence about who has the right of withdrawal is gone. **Cancelling asks why.** A reason from a fixed list, required, with a note beside it that "Sonstiges" cannot go without — the one fact worth having about a departure, asked at the only moment the customer is looking at the question. It lands on the contract (`cancel_reason`, `cancel_reason_note`). And where the fourteen days are still running, the dialogue offers the WITHDRAWAL as a way out of itself rather than as one more reason inside it: withdrawing ends the service the same day and sends the whole amount back, cancelling keeps the term that was paid for, and somebody entitled to the first should not lose it by taking the second unasked. A business is never offered it — WithdrawalRight answers that, not the template. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
49d528dbea |
Fix round: point at the real tab, hide the button nobody can press, prove the quiet page load
Fixes four Important findings from the review of Task 12.
Corrected nine check `tab` values at the source (BillingChecks was already
correct; OnboardingChecks/ProvisioningChecks/DeliveryChecks all carried the
pre-redesign 'integrations' value, which is not a member of
Integrations::TABS): four onboarding checks now point at 'platform' or 'env'
depending on where their field actually is, five provisioning/delivery
checks point at 'services'. checkUrl()'s match-block resolver — praised as
correct for the six checks that point at a genuinely different admin page —
is unchanged; its `default` arm is now a pure safety net, not a route any
check actually relies on.
Wrapped the "Prüfen" button in @can('secrets.manage'): mount() admits
hosts.manage OR secrets.manage, but runCheck() requires secrets.manage alone,
so an Admin-role operator could see a button that 403s on press.
Fixed a docblock on HEARTBEAT_KEYS that asserted a staleness threshold was
kept in sync with OperationChecks::STALE_AFTER_MINUTES — that constant is a
key/settings-name map with no threshold of its own, and no such
synchronisation exists.
Three new tests, each confirmed red against a deliberately reintroduced
version of the bug it covers before being confirmed green: every check's tab
value against where its field actually lives, the run-check button hidden
from an operator who cannot press it, and Http::assertNothingSent() after a
plain page load.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
1d4e98eb06 |
Rebuild the customer's settings page as four tabs
It was one column 768 pixels wide inside a 1240-pixel shell — a third of the window empty beside it — holding everything a customer might ever change: the company address, their password, two-factor, the devices they are signed in on, the branding of their cloud, the contract, and the button that closes the account. Two thousand pixels of scroll, and the way to a particular thing was knowing how far down it lived. Four tabs, by what somebody came to do: their details, security, the appearance of their cloud, the contract. The same tab bar the console uses, so one vocabulary holds on both sides of the login, and the choice lives in the query string — `#[Url(history: true)]`, no `except`, so the parameter is there from the first render and a link to this page can name the part it means. An unknown tab out of the address falls back to the first rather than rendering a settings page with no section on it. The width is used now. Inside a tab the short cards sit side by side instead of each taking a full row to hold three lines: the customer type beside the company form (it is one question, and beside the VAT field it was mistaken for it), the password beside two-factor, the logo beside the colours, the contract beside the closing of the account. Also: the subtitle listed the old sections by name. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
ff92466b47 |
Give the order a checkout page, and stop hiding what a year costs
**There was no checkout page.** The package list posted straight to Stripe, so the last thing a customer saw in this product's design was a grid of cards and the next was somebody else's form asking for a card number. What they were buying, what it costs in total, what happens afterwards and what they were agreeing to were never stated in one place. App\Livewire\Checkout is that place: the package and its figures on the left, a sticky summary on the right — net, the saving where there is one, the setup fee, the tax, what is due today, and what will be charged after it. Every figure comes from the catalogue, TaxTreatment and SetupFee, the same three the checkout itself reads; nothing is recomputed, because a summary that works out its own total is a second answer waiting to disagree with the one Stripe is handed. **The terms acceptance moved to where the money is spent.** It sat at the top of the package list as a condition of LOOKING at prices, with every button dead beneath it. It is a condition of buying, so it is now at the foot of the summary, directly above the button — after the total, which is the order those three things are read in. The package list has no form on it at all now; its buttons are links. **The page used the left half of a 1240px shell.** A 68-character title, a switch under it and an 80-character footnote, beside a grid that used the whole width. Now: a header band with the name left and the term switch right, where a control belongs, and a closing band of three columns instead of a paragraph trailing off. **The yearly view showed the monthly equivalent as its headline**, so on a package with no free months the big figure did not move at all when the switch was thrown — the page looked like it had ignored the click. The headline is the price of the term in the unit it is charged in; what a year works out to per month is said underneath. Same on the public sheet. **The switch was unreadable.** `text-on-ink` is not in this palette — the ink pairing is bg-ink/text-bg, as x-ui.button has always had it — so the label kept its inherited grey on a near-black pill. And its shell was rounded-lg with rounded-md options inside: two radius scales, one of them Tailwind's untokenised 6px. One scale now, nested. **The mails drew an empty orange tile.** The mark's ascent lives in an SVG the site loads and a mail cannot — Gmail strips inline SVG, Outlook renders with Word, and this layout carries no image by design. The shape is drawn with a character every mail font has, white on the accent tile, with the whole wordmark beside it. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
e83b17ec37 |
Put every missing field on one page before a customer pays
Admin\Readiness renders App\Support\Readiness::byGroup() end to end: state, label, breaks sentence and a fix link for every check, a button on the four that have to ask a real service (DnsTokenCheck, WireguardEndpointCheck, VmTemplateCheck, StripeCheck) and only ever run on demand, and one headline line — ready for the operator's current mode, or how many blocking items are still open. Wired into the nav and into Overview's own notice list beside the same gate as Admin\Integrations, since both halves it configures come back together on this page. Fixed two check keys the guard test caught for real: onboarding.ssh_key and delivery.inbound_password did not textually contain the vault key they check (ssh.private_key, inbound_mail.password), which is exactly the kind of silent gap this whole task exists to catch. Renamed to onboarding.ssh_private_key and delivery.inbound_mail_password, with the two existing test files that referenced the old keys updated to match. Also fixed the guard test's own assertion: Pest's toContain() is variadic, so a second string argument is a second required substring, not a failure message (the same trap tests/Feature/ModalHeightTest.php already documents about itself) — it failed on every entry, including ones already covered. Replaced with str_contains()+toBeTrue(), which keeps the check and the message both. Two pre-existing Overview tests assumed a bare install has nothing to report; it now correctly always starts with a readiness gap, so both were narrowed to what they actually test — no invented data, no invented incident. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
b32c6fc33f |
Sell the year as well as the month, and say what it saves
Both prices have existed since the catalogue did — one `plan_prices` row
per term, each with its own Stripe Price, and the checkout has taken a
`term` all along. Nothing ever sent one. The shop showed the monthly
figure, the order page posted no term, and the yearly Price sat there
being paid for by nobody.
**The console asks for free months, not a second price.** It used to take
the yearly TOTAL as its own free-form figure, so nothing in the system
knew WHY 588 belonged to a package costing 49 a month — and no page could
say "zwei Monate gratis" without a person working it out again and writing
it somewhere it would then drift. Now: the monthly price and how many of
the twelve months are free. The total is derived from the two
(PlanVersions::yearlyCents), previewed live on the form, and written to
the catalogue from the same arithmetic.
`free_months` sits on the VERSION, beside the capabilities publication
freezes, because that is what it is — part of the terms a customer bought.
The migration reads it back out of the prices that already exist rather
than defaulting to nought: an installation selling twelve-for-ten must not
lose that on a deploy. Only an exact division counts; a hand-negotiated
yearly figure keeps its amount and simply goes unexplained.
**The customer chooses.** A switch on the order page and on the public
sheet, both figures rendered and one shown, so switching costs no request.
The headline stays "per month" in either term — a yearly total as one big
number reads as five times dearer at a glance — with the amount actually
taken, the net figure, the months free and what twelve monthly payments
would have cost underneath. The form posts the term the customer was
looking at, and the checkout picks that term's Stripe Price.
The billing card now says which term is running. It states a monthly
figure whatever the term, so a yearly customer was reading a number that
never appears on a statement.
Also: **a Blade comment must never spell a directive.** Blade compiles
what is inside `{{-- --}}` too — the comment is stripped from the output,
but its directives are compiled on the way there. A comment explaining why
the parenthesised php directive had been avoided produced exactly the open
PHP tag it warned about, and the rest of the file was swallowed as PHP
source: no exception, no warning, the page one block short. That is how
the term switch vanished after being written. BladeCommentsTest scans for
the forms that open a tag.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
fed4acf31c |
Accept terms instead of a start date, and fix the sender no server accepts
**The test mail was rejected: "553 5.7.1 Sender address rejected: not owned by user no-reply@…".** Every purpose mailbox here has its own SMTP account, and a mail server lets an account send only from the address it owns. Mail::to() hands back a pending mail bound to the DEFAULT mailer and sendNow() then ignores the mailer the mailable asked for, so a mail addressed support@ went out over the no-reply@ login. It sends through the mailable's own mailer now, and the cloud-ready preview — the one mailable with no mailbox at all — takes the provisioning mailbox like the real notification does. Writing the test for that found the same bug in production code: **InvoiceMail and OrderConfirmationMail set their From to billing@ and never named a mailer**, so both went out over mail.default's no-reply@ login. On this installation no customer had ever received an invoice mail or an order confirmation; they rendered perfectly, queued without complaint and were refused at the door. MailSenderOwnershipTest now scans for the mismatch: a mail that takes a mailbox From must use that mailbox's mailer. **The box on the order page accepts the terms now**, not an immediate start. It used to carry the whole FAGG §16 sentence, which read like a choice between "now" and "in fourteen days" — and there is no second option. The terms are what regulate the sale, so they had to exist: resources/views/legal/terms.blade.php replaces the placeholder with fourteen sections written from what this software actually does — the delivery, the capacity queue, the full refund on withdrawal, the cancellation at period end, the deletion deadlines. The company data comes from CompanyProfile, so the page and the invoices cannot drift. No availability figure and no liability cap has been invented. No order goes through without it: the button is unusable until the box is ticked and says why, and CheckoutController still refuses server-side — the browser half refuses nothing. The request field is `terms_accepted`; the Stripe metadata key stays `immediate_start`, because a session opened before a deploy is paid after it and the webhook would find nothing under a new name. **"Wird der Account nach fünf Tagen gelöscht?"** Only an unconfirmed one. That was the whole of what we said, so the answer looked like yes. The second rule now exists and is stated: PruneDormantAccounts removes a confirmed account after a year when it never had a package — no customer record at all, which is where every order, contract and seven-year invoice hangs. A fortnight's notice goes out first, once, and `dormant_warned_at` is what permits the deletion: an account whose warning never went out is never removed. Signing in resets the clock, measured off the device rows because users has no last_login_at. Both deadlines are said in the portal settings, on the verification page, and in the terms — each reading the number off the command that enforces it. Also: the wordmark scan matched any element whose text merely BEGINS with the company name, which a paragraph of terms does. It looks for the lockup form now (no whitespace after the tag), which is what it was always about. The seven checkout tests in the parallel session's files were posting the old field name and now post the new one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
96f171b3b3 |
Put the switch where it governs, and say when it is thrown
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
c354ad1463 |
Make mails readable on a phone, and let an operator look at one
Three things the owner reported, and one sentence that promised something
this product does not do.
**The mails had a fixed width.** components/mail/layout.blade.php set
width="600" and width:600px, so every phone showed a 600px canvas in a
390px window and one had to scroll sideways to read a sentence. Now the
table is width="100%" with max-width:600px — fluid, with 600 as a ceiling
rather than a floor. A mail cannot fix this with a media query (Outlook
renders with Word and drops <style> blocks), which is why the whole file
is inline attributes and why the shrinking has to be built in. Side
padding went 40px -> 24px in the layout and in all nine mail views:
40 each side leaves 278px of a 390px screen to read in.
**There was no way to look at a mail.** An invoice mail needs an invoice,
a maintenance announcement needs a window, and "register an account to
see whether the confirmation reads well" is not a workflow — which is how
the fixed width survived for months. App\Services\Mail\MailPreviews
builds all nine from sample data, every record with make() and never
create(), so a preview leaves no invoice, order or window behind and
draws no number from a series. Two ways to see one: in a tab as the
document itself, or sent to the signed-in operator's own address. There
is no recipient field — that would be a form for mailing strangers.
sendNow(), not send(): every mailable here implements ShouldQueue, and
send() defers to the queue for those, which would have put a failure in a
worker's log while the button reported success to somebody waiting for
the mail. The test caught that.
**The booking page had no shell.** Order declared layouts.portal — the
bare shell the sign-in pages use, no navigation and no padding — so the
page sat flush against the top and bottom of the window with no way back
to anything. layouts.portal-app, like every other portal page.
**The consent box said something untrue.** Its refusal read "otherwise we
can only build your cloud once the withdrawal period has ended", which
describes a path that does not exist: nothing here delivers fourteen days
later, and an unticked box simply means no order. Said plainly now, and
said on the page before somebody runs into the refusal.
Also: GrantedPlanPortalTest asserted assertDontSee('179'), which matches
any id containing those digits — and ids climb through a suite run
because a rolled-back transaction does not reset an auto-increment
counter. It passed alone and failed in company. Asserts on '179,00' now,
which is what a leaked price would actually look like.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
3e7026407b |
Stop inventing a package, and rebuild the booking page
tests / pest (push) Failing after 8m12s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Two pages said opposite things about one account, straight after registering: the dashboard said "noch keine Instanz", and Paket & Addons said "AKTUELLES PAKET Start — Status Aktiv". The dashboard was right. The package page fell back to `$instance?->plan ?? 'start'` and rendered that fallback as a contract the customer had entered into, complete with a status of Aktiv, on the one page somebody opens to find out what they pay. The contract is resolved from the CUSTOMER now, not from the instance — a paid order parked for want of a machine has a subscription and no instance yet, and asking the instance would tell somebody who has paid that they have no package. An instance counts as well, whatever its subscription row says. With neither, the page says so and points at the booking page: an "Upgrade" from nothing is a purchase, and that has its own page. The cart stays outside that gate. A customer can have something in it before they have any contract at all, and hiding it there would hide the thing they came to pay for — which is what my first version did, and seven existing tests said so. And the booking page. Five cards in a four-column grid left the fifth alone on its own row, reading as a mistake; the consent box sat at the BOTTOM with every buy button disabled until it was ticked, so the page arrived looking broken before anybody had done anything wrong; and the price ran across the card in one block with nothing to compare. Now the consent comes first, because it is the condition for everything under it, and it colours itself green once given. Three columns, so five plans read as 3 + 2. Each card is the same four rows in the same order — price, delivery, what you get, the button — so the eye can run across a row and compare like with like. The button stays ENABLED with the reason it will not go through said under it: a disabled control explains nothing, and the server is the gate either way. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
2321854967 |
Discard an unconfirmed registration, and sweep the abandoned ones
tests / pest (push) Failing after 8m17s
Details
tests / assets (push) Successful in 19s
Details
tests / release (push) Has been skipped
Details
"Abmelden und neu registrieren" was advice that could not work. Signing out frees nothing: the address is still held by the unique index, so registering again with the address somebody meant fails — and every abandoned attempt stayed in `users` for ever. Reported exactly that way, and it was right. The button discards the account instead. Only an UNCONFIRMED one, and only one with nothing behind it: the webhook creates a customer's login from a paid checkout, so a user can sit on this page unconfirmed while already having a contract, and deleting that is data loss dressed up as a convenience. It refuses and says where to go. A confirmed account is closed, not discarded — that is ConfirmCloseAccount, with an invoice history behind it. R23: confirmed in a modal. The modal mutates nothing; it dispatches the event the page listens for, so the checks stay in the one place they already are rather than being duplicated where they could drift. And nothing has to be discarded by hand. clupilot:prune-unverified removes registrations nobody confirmed after five days — long enough for somebody who signed up on a Friday and found the mail in a spam folder on Monday, short enough that a typo does not hold the correct address for a month. It skips a confirmed account whatever its age, and any account with a customer record behind it, matched on user_id AND on the address, because either link means somebody is a customer. Every removal is logged with the address: a line that disappears is what somebody asks about later, and a count answers nothing. The deadline is said on the page as well as in the mail. Somebody who never received the mail is looking at the page, and the deadline is the reason they do not have to do anything about an attempt they abandon. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
f49f1dca09 |
Say when no mail is being delivered at all
tests / pest (push) Failing after 8m40s
Details
tests / assets (push) Successful in 28s
Details
tests / release (push) Has been skipped
Details
"Ich versuche mich zu registrieren, erhalte aber keine Verify-Mail, auch nicht beim erneuten Senden." Traced rather than guessed, and the evidence is in storage/logs: the address appears three times, so the mails were built and handed to a transport that writes them to a file. MAIL_MAILER=log. MailboxTransport::resolution() short-circuits on it — NON_DELIVERING is ['log', 'array', null] — so every purpose mailer becomes the log transport however well the five mailboxes are configured, which is correct for a development machine and exactly what was happening here. The reason it deserves a notice is that the failure is silent in every direction an operator would look. Mail::queue() succeeds. No job fails. The queue empties. And the console's own mailbox test reports SUCCESS, because MailboxTester builds its own transport on purpose — a check that honoured MAIL_MAILER=log would report success while writing to a file. So every instrument on the page said fine. Now the front page says it, linked to the mail page, and the mail page says it above the mailboxes it makes decorative. All three swallowing values are named, not only 'log': array and an unset default do the same thing, and a notice that only knew about one would be silent in the two cases nobody thinks to check. Two existing tests asserted that a clean installation raises no notice, and went red — correctly, because phpunit.xml forces MAIL_MAILER=array so a suite cannot send mail. They pin a delivering mailer now: the warning is about the environment, those tests are about the estate. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
70fa136e22 |
Say on the front page that the site is switched off
tests / pest (push) Failing after 8m54s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
"Ich komme auf dem lokalen Dev-Server nicht auf www.dev." Diagnosed rather than guessed: Laravel answers 200 on that host, and the request never reaches it — site.public is false, so PublicSiteGate returns the placeholder with a 503 to every visitor who is not on the management VPN and not signed in as an operator. Which is precisely what a broken deployment looks like from outside, and the only place that said so was the switch itself, on another page. So the console says it now, on the page an operator opens when something looks wrong, and the notice links to the switch. The second half of the same report — being sent to app.… — is the missing SITE_HOST. Without it the marketing site is registered without a hostname: it answers on the PORTAL's host as well, and every route() it generates points at APP_URL, so a link on the site lands on the portal's domain. Also said, also with the host named. Only where the installation is host-separated at all, though. A fresh checkout binds nothing to a hostname by design — RestrictAdminHost: "upgrading must not lock anyone out of a system that was working" — and a warning there is furniture. My first version fired on every clean installation and two existing tests said so immediately; they were right. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
a84251260c |
Read the mailbox password where it actually is, and let the console test it
tests / pest (push) Failing after 9m6s
Details
tests / assets (push) Successful in 22s
Details
tests / release (push) Has been skipped
Details
The inbox kept saying no mailbox was set up while the password sat right
there on the settings page. isConfigured() read
config('services.inbound_mail.password'), which is only ever the .env
value: this project deliberately does not overlay stored secrets onto config
at boot — SecretVault's own docblock says so, and HttpMonitoringClient reads
its token at the point of use for exactly that reason. My docblock even
asserted the opposite mechanism, which I never verified. It reads the vault
now, and falls back to .env because SecretVault::get() already does.
And a way to find out. "Verbindung prüfen" saves the form first — testing
what is on screen while the server still holds the previous values would
report on a mailbox nobody configured — then performs the same four verbs
the real fetch performs: LOGIN, SELECT, SEARCH UNSEEN. A check that proves
something easier than the actual job is worse than none.
What it reports back is one of five fixed words, never the server's own
prose: an IMAP error carries the command it failed on, and for LOGIN that
command contains the password. Each word maps to a sentence an operator can
act on — address and port, mailbox and password, the folder.
The line saying when the mailbox was last reached is on both pages, and it
is written by the SCHEDULED run as well as by the button. Otherwise "last
checked" would mean "last time somebody pressed a button", which is the less
interesting of the two: a mailbox that stopped answering at three in the
morning is the one worth seeing, and nobody presses a button at three in the
morning. It is also shown when the answer is "never" — an empty inbox and a
mailbox that stopped answering look identical, and this line is the whole
difference.
A fetch against a mailbox that just refused the login now takes nothing in
and records the refusal, rather than reporting "nothing new" — which was
true and useless.
Also: HostStepsTest asserted the literal clupilot.com for a host name. That
was true until CLUPILOT_DNS_ZONE was set to clupilot.cloud on this
installation, and then a correct configuration failed a test. The zone is
pinned in the test now; what it is about is the shape of the name and which
address it carries.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
32a8bd9a88 |
Refuse the sale instead of reaching Stripe without a key
Task 5: Billing::purchase() now checks SecretVault for a stripe.secret before touching anything else — no customer, no plan lookup, no Order row — and the customer reads a plain sentence instead of a 500 page. HttpStripeClient's own secret() throws the same StripeNotConfigured exception rather than sending a request with an empty bearer token and letting Stripe's 401 stand in for the real cause. isConfigured() was fixed alongside it: it used to call secret() too, which would have turned every "is Stripe set up?" check (the checkout controller, the catalogue sync commands) into an uncaught exception the moment a key went missing — the opposite of what this task is for. It now reads the vault directly. The new guard is unconditional, including for the cart-only purchase types (storage, addon, plan changes) that never call Stripe themselves — a cart order nobody can ever pay for is pointless to create. That broke every existing Billing/Cart/Downgrade test that calls purchase() without a stored key; each affected fixture now deposits one via the new withStripeSecret() Pest helper. A few of those tests (CustomDomainAccessTest, DowngradeTest) were passing already but for the wrong reason — the new guard, not the check they were named for — since both only assert an order was NOT created; they get the same fixture fix so they still prove what they claim. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
9da1358802 |
Stop charging VAT to the customers who owe us none
A verified EU business outside Austria gets reverse charge — rate 0, no VAT line, the note on the invoice — and was still charged 214,80 € for a 179,00 € package, because one Stripe Price carried the domestic gross for everybody. There is no VAT line on their document to reclaim, so it was a flat 20 % surcharge on exactly the customers who read their invoices, and the document then stated the whole 214,80 € as net at 0 %, so they self-accounted their own VAT on a base a fifth too large. A Stripe Price can ask who is buying after all: there are two per sellable thing now, the domestic gross and the bare net, both live on one Product, and the checkout picks by TaxTreatment. The rule is Austria B2B 20 %, other EU B2B without VAT — a domestic business still pays the gross, reclaims it as input tax, and the price on the website is still the price charged for them. - stripe:sync-catalogue mints and archives both halves of every pair, per plan and per module; `reverse_charge` on stripe_plan_prices/stripe_addon_prices says which is which, and joining it on subscriptions.stripe_price_id says which one a running contract is billed on. A rate change moves the gross Price and leaves the net one alone, because the net owes nothing to the rate. - A status that changes after the sale converges: a verification (or a lapse) makes stripe:reprice-subscriptions move the contract onto the other Price with PRORATE_NONE, because the term is already paid for. The module items follow the same way. - Downstream follows: the invoice total equals what was taken, the proof register expects the figure this customer is actually charged (so a correct reverse-charge sale is no longer flagged as a mismatch, and an overcharged one is), the setup fee obeys the same rule, and the booking page quotes what it will charge. - StripeClient gained activatePrice(). Archived Prices were brought back in our own table and left inactive at Stripe, so a rate that moved and moved back pointed the catalogue at a Price no checkout could be opened for. An unverified number is still charged the gross — an unchecked string must never be a discount — and where the net Price is missing the checkout refuses rather than reaching for the domestic one. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
889b401faf |
Fix nine defects in the provisioning pipelines
The capacity park could not survive its own first poll. ReserveResources
polls every 120 s and declared a maxDuration of 60, and RunRunner measures
a step's budget from a started_at it deliberately does not reset on a poll
— so every re-entry was ruled timed out before the body ran, a timeout
consumes an attempt, and five of them failed a paid order in about six
minutes. The fourteen-day promise, the console's capacity queue and the
"go and buy a server" workflow were unreachable code. maxDuration is now
the whole park plus one poll interval, and a test drives a park through
the runner rather than calling execute() directly, which is why this was
invisible.
Every guest occ call ran as root. `docker compose exec` defaults to root
and the Nextcloud image's console.php exits 1 unless the caller owns
config/config.php, so all five call sites were failing on every instance.
There is now one builder, App\Support\NextcloudOcc, and a test refuses a
second: nothing in app/ may spell the invocation out by hand. deploy/
update.sh had learned this for our own container and nobody carried it
across.
RunAcceptanceChecks was terminal on the first no. certReachable() answers
false for a connect timeout as readily as for a missing certificate, so
one bad second ended a finished, certified Nextcloud as a failed order
with the instance released. The probes retry now and the run still fails
for good with the probe's own reason once the budget is spent; the two
facts a retry cannot change stay terminal.
The in-flight guard asked whether anything was running, not whether it
would do the work. App\Provisioning\WorkInFlight asks the second question,
reading the step lists the runner executes: a domain proven during a
restart is routed, and a storage pack booked during an address run is
delivered instead of charged monthly and forgotten.
The address pipeline ran its steps in the wrong order for the direction it
exists for. Nextcloud before the router in `address` and `plan-change`, as
in `customer`, because a run that got the certificate and then failed left
the customer's own domain serving an untrusted-domain error under a valid
certificate while the portal called it live. Safe in both directions; the
reasoning is written above the pipeline.
A stale guest_ip could never be corrected. ConfigureDnsAndTls compared the
hostnames but never the backend, and nothing re-read the address after the
build. It now records and compares `routed_backend`, and ConfigureNetwork
joins the `restart` pipeline — a cold boot is what moves a DHCP lease — and
asks for the address to be re-applied when the guest has actually moved.
Also: HetznerDnsClient::upsertRecord read only the first page of a zone
that pages at 100, so past a hundred records it created a second A record
for the same name and the cloud was up about half the time; the console's
retry left a live customer's order in `provisioning` for ever for any
maintenance pipeline; and a revived run's first pass burned an attempt on a
timeout that had already happened, because the clause written for it used
`??=`.
Seven test sites wrote config('provisioning.plans.*'), a key that has not
existed since the catalogue moved into the database. Every write was a
no-op, so each test proved something other than its name: the missing
template, the two snapshot regressions and the grandfathered-price scenario
are now actually constructed, ValidateOrder's reasons are asserted rather
than only its type, and the end-to-end run checks that the quota step
delivered something.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
|
|
|
|
8ca0d4d257 |
Say the same thing to the customer, the register and the bank
tests / pest (push) Failing after 8m5s
Details
tests / assets (push) Successful in 25s
Details
tests / release (push) Has been skipped
Details
Five places where the product told somebody a figure that nothing behind it produced. **The invoice page showed five invoices nobody was ever issued.** A mock that outlived its purpose: CP-2026-0003 to CP-2026-0007, a prefix belonging to no configured series, all "paid", a spend curve from 179 to 198 € and a next charge of 198 € on 01.08.2026 — behind ['auth','verified','customer.active'], so every signed-in customer saw it. Their real invoices existed in `invoices`, were rendered as PDFs and mailed to them, and appeared nowhere in the portal at all. The page now lists their own documents, newest first, with the numbers, dates and totals off the frozen snapshot, a Storno marked as one, and a download. The PDF route resolves the document against the signed-in customer in the query — a URL anybody can type is not made private by not printing it. No next charge and no chart: nothing here computes what Stripe will take next cycle, and a figure nobody has worked out is worse than a blank space. **The setup fee was quoted everywhere and charged nowhere.** On the price sheet, on the booking page, set by the operator in the console — and CompanyProfile::setupFeeCents() had no reader outside the two sentences that quoted it. It is charged now, as a second non-recurring line on the same checkout session, which Stripe puts on the initial invoice only. `orders.setup_fee_cents` records how much of the charge it was, so the whole total still goes back on a withdrawal while the register holds the PACKAGE's charge against the contract price and the invoice prints the fee as a line of its own. The booking page was also quoting the fee NET under the same sentence the website quotes GROSS — 99,00 against 118,80 — and now quotes what will be taken. **An upgrade click cancelled a booked downgrade and delivered nothing.** Nothing in this application marks a cart order paid, so the customer traded a downgrade they had genuinely booked for a cart line that would never be fulfilled. The booking now stands until the upgrade actually lands on the contract, where ApplyPlanChange clears it. The fulfilment seam is reachable for every type that can be paid — a module and a storage pack book through BookAddon, which was the missing path from a paid order to a SubscriptionAddon and left AddonPrices, SyncStripeAddonItems and clupilot:end-cancelled-addons with no caller behind a payment. A paid traffic pack logs an error rather than pretending: the metered allowance is a standing count of packs while a top-up is sold for one month, and which of the two is meant is not a guess to make here. That no path to `paid` exists yet is now stated in both places rather than in one. **The proof register called correct charges wrong.** expected_gross_cents used the CUSTOMER's rate, which is zero under reverse charge, against the domestic gross Stripe actually took — so matches_catalogue was false for every EU business sale charged exactly the advertised amount, and for every renewal on a contract with a module, which was held against `price_cents` alone. It compares against TaxTreatment::chargedCents() and against the whole term now. Where no money moved, charged and the flag are null instead of the expected figure agreeing with itself, and a plan change records the terms with the pro-rata figures in the snapshot rather than money that arrives again on Stripe's proration invoice. **The seller's own VAT id was on the invoice, unlabelled**, between the register number and the court. Labelled — and the footer's composition moved out of the TCPDF method, because inside one the only way to read what it says is to render a PDF and un-subset its fonts. One test enshrined a figure the code does not produce: a reverse-charge purchase recorded with no charged amount, asserting 17900/0/17900, where OpenSubscription always passes the charged figure and a real one records 21480/0/21480. Another asserted that an upgrade click cancels a booked downgrade. Full suite: 1676 passed. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
8440266ed3 |
Stop charging for a service that has ended
Nothing in this application could cancel a Stripe subscription — there was no such method on the client at all. So a customer who cancelled and a consumer who withdrew both stayed subscribed, the card went on being charged every month, and every one of those payments arrived as invoice.paid, drew a real number out of the gapless Austrian series and mailed the PDF, for a machine that had been switched off. Indefinitely. StripeClient::cancelSubscription() closes the first half, with the timing passed explicitly at the call site — CANCEL_AT_PERIOD_END for a cancellation, because the customer keeps the term they paid for, and CANCEL_IMMEDIATELY for a withdrawal, because that unwinds the contract and the whole amount has just gone back. The immediate form states prorate and invoice_now as false rather than inheriting them: a credit note of Stripe's beside our own Storno would be the same money twice. The second half is ApplyStripeBillingEvent::owesADocument(). Refusing every invoice on a cancelled contract would lose the legitimate final one — the cycle for a term the customer really did use, unpaid, dunned for three weeks and settled after the contract ended. So the question is not when the payment landed but what it is for: an invoice whose billed period STARTED before the contract ended is documented, one whose period begins at or after that moment is not. Neither is a contract marked cancelled with no date, nor an invoice with no period, because a number handed out in error can never be withdrawn while a missing document can still be issued. The register entry is written either way — the money moved, and that is where an operator finds what has to go back. A cancellation also took its date from the order date plus MONTHS, whatever the term said, so a yearly customer who cancelled in March lost the nine months they had already paid for. It reads current_period_end now, which Stripe keeps current for both terms; the month-walk survives only for a machine with no contract behind it, where nothing records a term at all. Stripe is asked first and our rows are written only if it agreed: a cancellation we could not make effective is the defect above wearing a "gekündigt" label on the settings page, and a cancellation has no deadline, so being asked to try again costs nobody anything. A withdrawal cancelled the OPENING invoice only, found by invoices.order_id — which a renewal and a module document leave null on purpose. A storage pack booked on day three and withdrawn from on day ten was therefore neither cancelled nor refunded. Every charge inside the window is covered now, one Storno per document and one refund per payment, each against the payment that actually took it: a single refund of the total against the opening PaymentIntent is what Stripe would have refused for exceeding it, and one idempotency key for the whole withdrawal would have had Stripe replay the first refund's answer for the second. StartCustomerProvisioning::resume() opened a missing contract and returned, and the only production call to IssueInvoice::forOrders() is after the order commits — so a worker killed in between cost that customer their invoice for good, silently, with nothing anywhere sweeping for it. resume() finishes that work too, guarded by the invoice already filed against the order and by invoices.sent_at, so however often Stripe redelivers it comes out as one invoice, one number, one mail. The order confirmation stays on the first pass alone: it has nothing to stamp. subscriptions.cancel_requested_at is new because the billing half of the application had no way to tell a cancelled contract from an untouched one. Deliberately not a status: until the term runs out this is a paying customer, which is the distinction EndInstanceService is built around, and `cancelled_at` beside it still means the day it genuinely ended. clupilot:verify-vat-ids is scheduled monthly on the first, and its docblock no longer points at a note in routes/console.php that never existed. Reverse charge rested on a one-off VIES answer, a withdrawn registration keeps earning rate 0 for ever, and the unpaid VAT on those invoices is the seller's — so the cadence is the width of that window, and the first of the month is the rhythm the return is filed on. Two tests that proved nothing are now about behaviour. SettingsTest asserted service_ends_at was not null, a column the code had just written and which the broken monthly arithmetic satisfied exactly as well as the right answer; it states the yearly date, the contract's own record and the order to Stripe. EndInstanceServiceTest travelled to whatever value the implementation had computed, so it recomputed the code instead of checking it, and never looked at billing at all; the boundary is a literal date now and the money stopping is asserted beside the address coming down. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |
|
|
|
518bc9aa41 |
Put the open tab in the address bar, including the first one
tests / pest (push) Failing after 11m2s
Details
tests / assets (push) Successful in 20s
Details
tests / release (push) Has been skipped
Details
Reported as "die Tabs fehlen in der Query beim Kunden". The binding was there and identical to the settings page — checked in the rendered snapshot, which is where Livewire's JS reads it from — but `except` hid the parameter while the DEFAULT tab was open. So arriving on a customer showed no ?tab= at all, and a link to the page could not say which tab it meant unless the tab happened not to be the first one. From the address bar that is indistinguishable from the feature not existing. No `except` now, on all three tabbed pages: the parameter is there from the first render. And `history: true`, so each tab is a step the back button can take rather than a silent replaceState — which also makes the change unmistakable while clicking. Asserted against the rendered page rather than the component, because the parameter is written client-side from the `url` memo in the snapshot: the memo IS the behaviour, and asserting the property would have passed throughout the bug. Co-Authored-By: Claude Opus 5 <noreply@anthropic.com> |