Commit Graph

107 Commits (4fc1ccd3dbbb035266add7f57879c74b7c71d37c)

Author SHA1 Message Date
nexxo 11a7c52862 Office Pro war Start zum Kauf angeboten, waehrend die Preistafel es dort ausschloss 2026-08-02 01:46:22 +02:00
nexxo ef4be9c518 ApplyPlanChange lehnt Wechsel auf interne Pakete ab statt sie ewig zu parken
Kippt die Zielfamilie eines gebuchten oder bezahlten Wechsels zwischen dem
Klick und der Ausführung auf internal, fand MoveStripeSubscriptionPrice nie
einen Stripe-Preis und parkte den Fehlschlag in stripe_price_sync – wo der
stündliche Sweep ihn für immer wiederholte, solange die Familie intern
bleibt. Der neue Deckel sitzt vor der Transaktion (Vertrag, Register und
Maschine bleiben unangetastet) und greift nur, wenn der Vertrag Stripe
überhaupt abrechnet; erkannt am internal-Flag der Zielfamilie statt an
fehlender Preis-Auflösbarkeit, weil letzteres auch den gewöhnlichen,
selbstheilenden Fall "noch nicht synchronisiert" träfe, den der bestehende
Park-und-Wiederhole-Pfad ausdrücklich abdecken soll.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 19:08:55 +02:00
nexxo da11d70037 Der Deckel hielt außerhalb der Transaktion, das Fenster versprach einen Block zu viel
Zwei Befunde der Codex-Durchsicht an derselben Grenze — höchstens drei Blöcke
je Vertrag.

Die Mengenprüfung stand in __invoke(), also außerhalb der Buchungstransaktion,
und für Speicher wurde der Vertrag absichtlich nicht gesperrt. Zwei
Speicher-Bestellungen desselben Vertrags nahe der Grenze lasen damit beide
denselben alten Stand und fügten beide ein; der eindeutige Index über
(order_id, addon_key) greift dabei nicht, weil es zwei verschiedene
Bestellungen sind. Die Prüfung zählt jetzt in book(), unter der Sperre — und
die vorhandene Sperre gilt zusätzlich für gedeckelte Module, statt eine zweite
daneben zu nehmen. Der Idempotenz-Kurzschluss bleibt davor: eine wiederholte
Zustellung derselben Bestellung bekommt ihre Buchung zurück, statt ausgerechnet
am ausgefüllten Deckel zu scheitern (dieselbe Reihenfolge wie bei der
Kapazitätsprüfung).

Und im Bestätigungsfenster machte max(1, min($max, $packs)) aus null buchbaren
Blöcken wieder einen — dieselbe Falle, die in Billing::purchase() schon behoben
war. Bei null buchbaren Blöcken zeigt das Fenster jetzt die Absage in dem Satz,
den der Kunde zu dieser Grenze überall sonst liest, statt einen Kauf
anzubieten, den purchase() danach ablehnt.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:53:00 +02:00
nexxo d4f534fbbb Interne Pakete gehören nicht nach Stripe
stripe:sync-catalogue überspringt jetzt jede Familie mit internal = true —
weder Produkt noch Preis, im Trockenlauf wie im echten Lauf, und zwar bevor
irgendetwas über sie gelesen oder geschrieben wird. Ein Stripe-Preis lässt
sich nicht löschen, nur archivieren, und ein Paket, das nie über Stripe
abgerechnet wird, gehört deshalb nicht ins Konto. Eine Familie, die bereits
Stripe-IDs trägt (verkäuflich war, jetzt intern ist, wie Enterprise), bleibt
unangetastet: nichts wird gelöscht oder ersetzt, es kommt nur nichts Neues
mehr hinzu.

BillingChecks::billing.catalogue_synced bekam dieselbe Ausnahme — sonst wäre
die Bereitschaftsseite durch genau diese Änderung dauerhaft rot geworden,
weil das interne Testpaket und Enterprise veröffentlicht und sales_enabled
sind, ihre Preise aber nie synchronisiert werden.

AddonPrices, SyncStripeAddonItems, stripe:reprice-subscriptions und
stripe:sweep-orphan-prices wurden geprüft: alle vier sind bereits sicher,
weil ein verschenkter Vertrag nie ein stripe_subscription_id trägt und die
anderen beiden Befehle nur über vorhandene Stripe-Objekte laufen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 18:31:37 +02:00
nexxo 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>
2026-08-01 17:53:06 +02:00
nexxo f961205512 Die neue Leiter: Start 39, Team 79, Business 139
Zugeschnitten auf die Maschine, die dasteht. 388 GB vergebbar geteilt durch
40 GB Platte sind neun Startkunden statt drei — Speicher und RAM gehen damit
gleichzeitig aus, wo vorher sechs RAM-Plaetze verfielen. 351 Euro Umsatz je
Server statt 147.

Umgeschaltet wird durch Beenden und Nachfolgen, nicht durch Aendern: eine
veroeffentlichte Version ist unveraenderlich, und laufende Vertraege tragen
ihren eigenen Schnappschuss. Als Migration und nicht als Befehl, weil der
Katalog von einer Migration gesetzt wird — sonst blieben Neuinstallation und
Testsuite auf einer Leiter, die wir nicht mehr verkaufen.

Enterprise verlaesst den Verkauf (2000 GB finden auf 388 GB keinen Host), das
Testpaket heisst Intern und wird intern — es steht ausserdem zum ersten Mal im
Katalog einer frischen Installation, statt von Hand angelegt werden zu muessen.

Dreissig Testdateien nennen die neuen Zahlen. Drei Faelle waren keine Zahlen:
Vertraege auf Enterprise entstehen nur noch als Bestandsvertraege (Helfer
asGrandfathered() in tests/Pest.php), die Preisblatt-Stufe "Premium" haengt am
Paket, das den Laden verlassen hat, und ein Katalogleser, der die einzige
Preiszeile einer Familie suchte, findet seit der Handreichung zwei.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 17:05:58 +02:00
nexxo f8d0c2c353 Merge branch 'claude/angry-golick-187172' into feat/neue-pakete 2026-08-01 16:27:13 +02:00
nexxo 3b5dee8f0d Ein Ausweg wird nur angeboten, wenn er auch buchbar ist
DowngradeCheck baute seine Empfehlung aus packsToCover(), und die rechnet
bloss. BookAddon lehnt aus ZWEI Gruenden ab - der Deckel bei drei Bloecken
und der Ausschluss von Enterprise -, und die Empfehlung kannte keinen davon.
Solange ein Block 100 GB brachte, war das unerreichbar; seit er 20 GB bringt,
landet jede Ueberschreitung ueber 60 GB dort.

Business -> Team mit 600 GB belegt bot "5 x Zusatzspeicher buchen (+100 GB)"
an. Das Modal klemmte still auf drei, den vierten haette BookAddon abgelehnt,
und der Kunde stand nach 45 Euro im Monat genau dort, wo er vorher stand - auf
der Karte, die sein Abo billiger machen sollte. Enterprise -> Business empfahl
Bloecke, die es fuer dieses Paket ueberhaupt nicht gibt.

AddonCatalogue::bookableQuantity() antwortet jetzt auf beide Gruende. Es gab
einem Enterprise-Vertrag "3" auf die Frage, die sein eigener Docblock stellt.

DowngradeCheck stellt diese eine Frage und klemmt daran: `packs` ist die
gebrauchte Zahl nur, wenn der Vertrag sie auch buchen darf, sonst null - und
dann traegt `short`, was nach allen buchbaren Bloecken uebrig bliebe, gemessen
an DEREN Restmenge statt am Deckel, damit ein Kunde mit einem Block nicht mehr
zu loeschen bekommt als noetig. Kein halbes Angebot: eine Dauerbuchung, die
den Wechsel trotzdem nicht freigibt, ist kein Ausweg, sondern der ausgegraute
Knopf mit Preisschild.

packsToCover() bleibt reine Arithmetik, mit einem Kommentar, der sagt warum:
die kaufmaennische Grenze steht in AddonCatalogue, und ein Klemmen an dieser
Stelle zoege eine Vertragsabfrage in jeden Kontingent-Schritt und ins Portal,
die beide keine Verkaufsfrage stellen.

Der Knopf haengt schon an `packs > 0` und verschwindet von selbst; der Satz
wechselt auf downgrade_escape.capped, der die verbleibende Luecke nennt und
nicht den Grund - "hoechstens drei Bloecke" waere im Enterprise-Fall falsch,
wo es gar keine gibt.

Zwei bestehende Tests hingen an 600 und 800 GB aus der 100-GB-Zeit, beide
inzwischen nicht mehr deckbar; einer haette das Modal gesucht, das die Karte
zu Recht nicht mehr oeffnet. Auf 550 und 560 GB umgestellt, wo sie das pruefen,
wofuer sie geschrieben wurden.

Dazu ein Test, der belegt statt annimmt, warum das max(1, min(...)) in
ConfirmBookStorage stehen bleiben darf: ein Modal ist per openModal direkt
erreichbar, aber bookStoragePacks() legt weder am Deckel noch bei Enterprise
eine Bestellzeile an. Die bestehenden Tests deckten purchase() ab, nicht diese
Weiterleitung.

Voller Testlauf: 2405 bestanden. Pint sauber.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 15:40:26 +02:00
nexxo 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>
2026-08-01 15:31:46 +02:00
nexxo 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>
2026-08-01 15:09:02 +02:00
nexxo af05ed6694 Kapazitaetspruefung hinter den Idempotenz-Kurzschluss verschoben
Durchsicht (R22): die Pruefung stand in __invoke(), vor book()s Kurzschluss
fuer einen wiederholt zugestellten Webhook ("Idempotent against a retried
webhook"). Damit fragte jede Wiederholung erneut "passt NOCH ein Block
drauf" - obwohl keiner hinzukommt - und ein laengst bezahlter, laengst
gebuchter Vorgang quittierte die Wiederholung mit einem Fehler, sobald der
Host zwischenzeitlich eng geworden war. Genau das Szenario, fuer das diese
Aufgabe gebaut wurde, nur gegen den eigenen Kunden gerichtet.

Jetzt sitzt die Pruefung in book(), hinter dem order_id+addon_key-Kurzschluss:
eine Wiederholung bekommt ihre bestehende Buchung zurueck, ohne die Frage
erneut zu stellen. Eine echte neue Buchung durchlaeuft die Pruefung wie
zuvor. Deckel (quantityRefusal) und Domain-Ausschluss bleiben unangetastet -
sie haben dasselbe Muster im Kleinen, sind aber nicht Gegenstand dieses
Befundes.

Neuer Test: derselbe Auftrag wird zweimal gebucht, der Host wird zwischen
den beiden Aufrufen eng - der zweite Aufruf gibt die vorhandene Buchung
zurueck statt zu werfen.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:18:01 +02:00
nexxo 01bcc29d13 Ein Block wird nur gebucht, wenn der Host ihn tragen kann
HostCapacity wurde im Checkout, in der Bestellung und im Konsolenbereich
gefragt — beim Aufstocken nicht. Weil qcow2 duenn belegt ist, gelingt die
Ueberbuchung sofort und faellt erst auf, wenn die Gaeste wirklich schreiben.
Gefragt wird der Host der Instanz, denn eine laufende Instanz zieht nicht um.

StorageAllowanceTest: der Host in storageFixture() war mit 1000 GB (active()
Standard) schon fuer eine reine Business-Instanz (1050 GB disk_gb) zu klein —
placeableIn() haette sie nie dort platziert. Ohne Kapazitaetspruefung fiel das
nie auf; jetzt schon, deshalb auf 2000 GB angehoben.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 14:03:07 +02:00
nexxo 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>
2026-08-01 13:50:07 +02:00
nexxo 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>
2026-08-01 13:33:36 +02:00
nexxo 2ab6a92fd8 Die Packungsgroesse steht auf der Buchung, nicht in der Konfiguration
Bisher las StorageAllowance sie bei jeder Anzeige neu aus config. Solange es
eine Groesse gab, war das folgenlos — beim Zuschnitt von 100 GB auf 20 GB waere
es der stille Verlust von 80 GB je gekauftem Block gewesen, bei einem Kunden,
der bereits Daten darin liegen hat.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 12:25:30 +02:00
nexxo e0c0bb2c29 Release v1.3.83 — Fortschritt zeigte Übersetzungsschlüssel
tests / pest (push) Failing after 8m48s Details
tests / assets (push) Successful in 23s Details
tests / release (push) Has been skipped Details
Unter jedem Schritt stand `dashboard.step_state.done` statt eines Wortes —
im Adminbereich UND im Kundenportal, wo derselbe Stepper jemanden durch die
Einrichtung seiner Cloud begleitet.

Die Schlüssel gab es in keiner der beiden Sprachdateien. Die Paritätsprüfung
aus R16 konnte das nicht finden: sie vergleicht Deutsch mit Englisch, und die
beiden waren sich einig. Der Aufruf ist zusätzlich zusammengesetzt
(`__('dashboard.step_state.'.$state)`), also findet ihn auch keine Suche nach
festen Zeichenketten. Der neue Test rendert deshalb das Bauteil und prüft alle
vier Zustände in beiden Sprachen.

Dazu ein Test, der heute Nacht hochgegangen ist: PortalInvoicesTest verbot die
Zeichenkette '01.08.2026' — das Datum aus einer alten Attrappe. Heute ist der
1. August 2026, und die echten Rechnungen des Tests tragen das
Ausstellungsdatum von heute. Eine Zusicherung, die ein Datum verbietet,
verbietet auch den Tag, an dem es echt wird. Was die Attrappe ausmachte, war
ihr Satz, und den prüft der Nachbartest über 'Nächste Abbuchung' — stabil,
weil er nirgends sonst entsteht.

2235 Tests grün.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-08-01 02:08:28 +02:00
nexxo 7c4d4fd11c Zweite Fix-Runde: Regressionen der ersten (Trockenlauf, Doppelmail, Bestand, Isolierung) 2026-07-31 23:02:13 +02:00
nexxo b15fcc53a5 Fix-Runde: Reichweite der Sperre, keine doppelte Gebuehr, verlorene Nachricht 2026-07-31 22:57:56 +02:00
nexxo 4f037c8264 Mahnwesen: Einstellungen und die zwei Eingriffe von Hand 2026-07-31 22:43:52 +02:00
nexxo a64d41639b Mahnwesen: die sechs Kundenmails 2026-07-31 22:31:04 +02:00
nexxo a9f15449c6 Mahnwesen: Sperre und Freigabe, zusammen gebaut 2026-07-31 22:19:42 +02:00
nexxo 9899152d95 Mahnwesen: Tageslauf mit Gebuehrenrechnungen 2026-07-31 22:13:52 +02:00
nexxo 29c2718235 R20: Begruendung im Modal, und der Waechter sieht jetzt auch Listenzeilen 2026-07-31 22:09:05 +02:00
nexxo 7f7b654fcb Zahlungsprobleme: geplatzte Kaeufe aufzeichnen, beide Arten an einer Stelle 2026-07-31 21:58:52 +02:00
nexxo 9304382f62 Mahnwesen: Fall, Zeitplan und Gebuehrenstufen 2026-07-31 21:51:42 +02:00
nexxo d6fd760a8b Offene Rechnungen bis zum Ende blaettern (Codex P2) 2026-07-31 20:58:21 +02:00
nexxo 82c6d62057 Stripe-Client: offene Rechnungen listen und einziehen 2026-07-31 20:04:09 +02:00
nexxo 0ae752d2ae Stripe-Client: SetupIntent und Vorgabe-Zahlungsmittel 2026-07-31 20:01:14 +02:00
nexxo 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>
2026-07-31 19:50:01 +02:00
nexxo 447d6fe0f3 Merge main into the host-takeover branch
One real conflict, in SyncStripeCatalogue::handle(), and both sides were right.

Main's parallel work resolves the adoption singletons and takes "before" counts
so a run can tell created from adopted. The operating-mode work refuses a
catalogue whose stored objects belong to the other mode, and records which mode
the catalogue now belongs to.

Kept both, with the mode guard first. It REFUSES, so it must not run behind
anything that has already built state; the counters only need to be in place
before the create loop, and they still are. Reversing that order would have the
command resolve singletons and take counts on a catalogue it is about to reject.

2017 tests pass.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 21:21:45 +02:00
nexxo 79654333a8 Name the recurring filter, and stop the spec contradicting itself
Three sentences left over from the wave before, all of the kind this branch
exists to end.

- "Every active Price" was one word short. activePricesFor() sends
  `type: recurring` as well as `active: true`, so a one-time Price is never
  listed — which made the hand-built-payment-link sentence false for a one-time
  link: neither listed nor archived. Both places say `recurring` now, and the
  docblock says why the filter is not a gap: createPrice() always sends
  `recurring[interval]` and is the only call that mints a Price at one of our
  Products, so the filter can only ever hide somebody else's one-time Price.
  A filter left out of a sentence is a gap the next reader goes looking for.
- The sweep test file still opened "The orphans nobody can adopt" — the exact
  narrowing the command's own docblock had to be corrected away from. It happens
  to describe the fixtures, which are all metadata-less, so it now says that
  instead of claiming it of the command.
- The design doc called --archive "harmlos" seven lines above the correction
  establishing that it is not, for an adoptable orphan. A document that
  contradicts itself seven lines apart teaches nobody anything.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:24:18 +02:00
nexxo a43be4186a Say what the sweep did not check, before telling anyone to archive
stripe:sweep-orphan-prices described itself as listing "what AdoptStripePrice
will not take over". It computes nothing of the sort: it lists every active
Price at one of our Products that no register row knows, adoptable or not — the
only question it asks is whether a row holds the id. And then it invited the
destructive path, with no word anywhere about running stripe:sync-catalogue
first.

Following that advice can make a plan unsellable. Archive an orphan the next
sync would have adopted and the sync can no longer see it: activePricesFor()
asks Stripe for active prices only, so adoption returns null and createPrice()
fires under the same key the crashed run used. For twenty-four hours Stripe
answers a repeated key by replaying the stored response — the archived Price's
id, in the body it had while it was live. The register writes it down as live,
and the checkout is refused. This is the same class of defect the branch exists
against: a comment that was eloquent and wrong.

- The docblock now says what the command lists, names the twenty-four-hour
  replay so nobody has to rediscover it, and the ordering requirement is in
  $description and printed on every path that found something, --archive
  included. The reassurance that archiving is harmless is now scoped to what
  THIS codebase sells; a payment link an operator built by hand against one of
  our Products is withdrawn all the same, and says so.
- stripe:sync-catalogue --dry-run says "created or adopted" again. A dry run
  counts intents without calling ensure(), so it cannot know which Stripe
  already holds — and a dry run is the first thing an operator runs after an
  interrupted one. The live line keeps both figures, where they are knowable.
- The sweep's plan side gets a test: an orphan at a family Product is reported
  and a known plan Price never is. Deleting either half of the lookup turns it
  red; the five tests it joins all stayed green for the products() half.
- The throttle test pinned "once" and neither "per price" nor "per day". A key
  mutated to a constant would have silenced every orphan after the first and
  kept it green — the silent no-op the branch is built against. It now plants a
  second orphan, and travels a day to hear the first one again.
- Two of my own documents corrected. The spec sentence claiming an adoptable
  orphan never appears in this list is what the implementation faithfully built;
  it holds only if a sync has run since the orphan appeared, which nothing
  enforced. And the two adoption classes do not carry the same run log:
  $adoptions on the Price side, $duplicates on the Product side.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 19:12:57 +02:00
nexxo 571acd0013 Check Stripe is configured on every path the sweep actually uses
The dry-run guard was copied from stripe:sync-catalogue, where it is right:
that command's --dry-run touches nothing, because everything it compares
already lives in our own tables. This command has no such local-only mode —
the report IS a live activePricesFor() call, dry-run or not — so `! $dryRun &&`
made the guard fire only on the one path that would have worked anyway. A
--dry-run against an unconfigured account fell straight into
HttpStripeClient's own exception instead of the clean error and self::FAILURE.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:48:49 +02:00
nexxo 277a6bab83 Give the operator something to do about an orphan nobody can adopt
The recognition step refuses a price that proves nothing about itself, and that
is right — adopting a stranger's price is worse than minting a second one. It
left an operator with a log line and no way to act on it.

Reports by default and touches nothing. With --archive the orphans stop being
sold, which is harmless for the reason it always is here: Stripe goes on billing
every subscription already on an archived price, and nothing is sold on a price
no row knows.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:36:06 +02:00
nexxo 048e5ba81f Subtract only what was counted, not everything a singleton adopted
A figure may only subtract what it counted as an intent in the first place. The
family loop counts a Product before knowing whether it will be adopted or
minted, so an adoption there could honestly be subtracted back out — but
syncModules() has never counted a module's own Product as an intent at all; it
only counts a module's Prices. Reading AdoptStripeProduct::adoptions as one
run-wide delta could not tell the two apart, so a module Product adopted from
an interrupted run silently inflated "adopted" and shrank "created" by exactly
one, for a Product the command never claimed to have made in the first place.

The fix counts the family side locally, at the one call site that already
counts the intent, and leaves a module's Product out of both figures entirely —
adopted or minted, it was never counted, so neither number may move for it.
AdoptStripeProduct::$adoptions is gone with it: nothing reads a singleton-wide
total that cannot be attributed to one side or the other, and keeping it around
unread would be exactly the kind of state this codebase does not leave lying
about.

The duplicate-product report had the same shape of bug one line down: it read
straight off the singleton's list with no before/after snapshot, so a second
handle() call in one process would reprint a duplicate an earlier run already
named. Sliced to what this run itself added, the same way the counters beside
it already were.

Also: the unread `$charged` line in the adoption test that TDD had already
exercised as dead weight, and its now-unused PlanPrices import.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 18:20:27 +02:00
nexxo df7da334ee Say what was adopted, not that it was created
The count is taken before ensure() runs, so the sweep reported objects created in
Stripe when it had made none — and after the next interrupted run, that line is
the first thing a human reads. Telling the two apart is the whole point of the
recognition step.

Comparing the price id before and after the call does not distinguish them
either: there is no id before, in either case. AdoptStripePrice counts its own
adoptions instead, which is why it and its product sibling are now singletons —
resolved per call, a counter on the instance would never pass one.

Duplicate products are named in the report as well as the log. We deliberately do
not deactivate them, so only a person can resolve one, and a person reads this.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:56:13 +02:00
nexxo 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>
2026-07-30 17:53:20 +02:00
nexxo d6ec09a9b4 Recognise the product Stripe already has
The gap beside the guard. A run that creates a product and dies before storing
its id leaves an orphan exactly as a price does, and past the key's twenty-four
hours the next run makes a second one — after which activePricesFor() is asked
about the wrong product, price recognition goes blind for the whole family, and
every orphaned price on the first product is unreachable.

Two things are unlike the price side, deliberately. No money gate: a product
carries no amount, so its metadata is the whole of the proof. And a duplicate is
reported, never deactivated — archiving a price is provably harmless because
Stripe keeps billing subscriptions already on it, but deactivating a product
makes its prices unsellable and contracts can be running on those.

Silent about strangers, too. This asks for every active product on the account,
so an operator selling something else through it is an ordinary state, not a
finding worth a line on every run.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:39:15 +02:00
nexxo 786318b6d4 Never tell anyone to delete a catalogue their contracts bill on
The refusal added in the previous commit had a state it read wrongly, and
the wrong reading was destructive.

record() sat at the END of handle() and in no try/finally, while
createProduct() and ensure() throw uncaught. A run that died after the
first object left hasStoredObjects() true and recorded() null. The same
state arises with no failure at all: CheckoutController → PlanPrices::
ensure() and BookAddon → SyncStripeAddonItems → AddonPrices::ensure()
mint missing prices and never call record().

In that state the next run — in the SAME mode — refused with the foreign
account message and its "Clear plan_families.stripe_product_id,
plan_prices.stripe_price_id and the … registers first", and
billing.catalogue_synced blocked with the same text. The objects were
from the account in force. The right move was to resume, which is what
the idempotency keys exist for; instead an operator was handed a delete
instruction for a catalogue live contracts are billed on.

Two changes:

  1. record() moves ahead of the creation loop, right behind the refusal.
     There it is already proved that either nothing is stored or what is
     stored belongs to the active account, so the moment carries the
     claim just as well — and a run that dies part-way can no longer
     leave a state that contradicts itself.
  2. "Origin never recorded" gets its own sentence and its own cure,
     separate from "established: other account".
     StripeCatalogueMode::matchesActiveMode() becomes
     belongsToAnotherMode(), which is only true where the other account
     is fact. The check still blocks — the origin cannot be proved — but
     it says "run the sync again", and it names no register to empty.

Red first:

  ⨯ it takes up a catalogue whose origin was never written down
      Failed asserting that 1 matches expected 0.
  ⨯ it leaves no half-built catalogue that contradicts itself when a run dies
      Failed asserting that null is identical to an object of class "App\Support\OperatingMode".
  ⨯ it tells an unrecorded origin apart from a foreign account

The two states are held apart by assertion, not by wording: only the
foreign-account sentence may name stripe_plan_prices, and the unrecorded
one must name stripe:sync-catalogue instead. The existing foreign-account
test keeps its teeth.

Full suite: 1817 passed, 6366 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:30:56 +02:00
nexxo fcea7ff3a8 Let the catalogue ask Stripe what products it already has
The price half of this question was answered; the product half was left open and
named as a known gap. It is the more consequential one: a second product makes
every activePricesFor() ask about the wrong one, so price recognition goes blind
for that whole family and every orphan on the first product is unreachable.

The whole active list comes back with no metadata filter, because Stripe cannot
search metadata and an account holds a handful of products. Deciding which are
ours belongs to the next commit.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:25:38 +02:00
nexxo 4b99c6d5c6 Make the fake behave like Stripe where it matters
Three ways it did not. It replaced metadata where Stripe merges — and merging is
the property AdoptStripePrice's "write only when it differs" comparison is built
around. It handed metadata back uncast where the wire returns strings, so a test
could pass on an int that production's strict === rejects forever. And a tie on
`created` fell to insertion order, so two runs could adopt different prices.

Paging now throws instead of stopping when Stripe says there is another page and
the last item carries no id. invoiceLines() stops there and it costs a short
document; here it costs an unseen orphan and a second live price for one figure.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:14:46 +02:00
nexxo 26cf1a72a0 Remember which Stripe account the catalogue was built in
The mode switches the credentials. It does not switch what was made from
them: a Price id and a Product id belong to the account that issued them,
and test and live are two accounts. Every credential got two slots on this
branch; the ids derived from them sit in single-valued columns.

So the planned sequence of this installation ended in the exact false
green this page exists to rule out — sync in test, store the live key,
switch, and billing.catalogue_synced went on reporting satisfied because
it only asked whether the column was filled. "Bereit für Livebetrieb",
and the first real order got "No such price".

Detection, not repair. Stripe does not put the account in the id —
prod_… and price_… look the same in both, only KEYS carry _test_/_live_
— so the origin cannot be read back out of a stored id, and asking
Stripe is out: this page reaches nothing over the network on a page load.
What is left is to write it down at sync time, which is what
App\Support\StripeCatalogueMode does.

One setting for the whole catalogue is only honest because
stripe:sync-catalogue now REFUSES a run into a catalogue that belongs to
the other account. Without that, the run would skip every row that already
carries an id, answer "already in step", and record an account it never
touched — the same lie one level down. The registers count as stored
objects too: inStep() takes a registered row as proof on its own for the
reverse-charge half.

The `breaks` sentence says what happens (checkout fails, no order) and
what actually helps, including the awkward half: re-running the sync is
not enough, the pointers and both registers have to be cleared first.

The slot migration backfills the one case it can prove: whatever is at
Stripe was made with the one key this installation has ever stored, so it
belongs to the account that key opens. Otherwise a long-synced catalogue
would read as "origin unknown" and the page would demand a re-sync nobody
needs.

Red first:

  ⨯ it does not call the catalogue synced when its ids belong to the other account
  ⨯ it says the sale is refused and a fresh sync is needed, not that a column is empty
  ⨯ it records the mode its objects were created in
  ⨯ it refuses to work into a catalogue that belongs to the other account
  ⨯ it syncs into the new account once the stale ids are cleared

Full suite: 1814 passed, 6357 assertions.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:12:56 +02:00
nexxo 40532c6e02 Put the money gate in the class that promises it
AdoptStripePrice says in its own docblock that no adoption can move money, and
could compare three properties. The four that also decide what a Price charges
were filtered in HttpStripeClient — where FakeStripeClient cannot express them,
so no test of the class could reach the promise it makes, and a third client
implementation would drop the check in silence.

The client reports them now and the caller decides. Absent keys stay Stripe's
defaults, which is the direction that matters: read the other way round the gate
refuses every legitimate price and recognition becomes a no-op nothing alerts on.

And the unexplained-orphan warning is a state, not an event. It fired on every
sweep and every customer booking for as long as the orphan existed; once a day
per price is enough to act on.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 17:04:25 +02:00
nexxo 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>
2026-07-30 16:41:32 +02:00
nexxo 865fd16f58 Put the price recognition back, all 3526 lines of it
2321854 removed it. That commit's own work — discarding an unconfirmed
registration and sweeping abandoned ones — is untouched and correct; what went
with it was every file of the Stripe price adoption merged an hour earlier:
AdoptStripePrice, IdempotencyKey, the unique-index migration, both test files,
the spec, the plan, and the edits in six more.

Nothing was lost. The commits stayed in history and the files stayed on disk,
untracked, which is what a staged deletion from a stale tree leaves behind.
Restored from 4eb90c8, the reviewed head, by explicit path — the fourteen paths
2321854 damaged and not one more, so the ten files that commit legitimately
added or changed keep exactly what it gave them.

This is the failure the repo's own rule exists to prevent: stage by path, never
`git add -A`, because a second session's index does not know what a first one
merged.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:35:55 +02:00
nexxo 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>
2026-07-30 16:25:10 +02:00
nexxo 8479fc30f8 Fix the checkout link, and stop the switch moving under the cursor
**Every package button led to "Dieses Paket steht nicht (mehr) zum
Verkauf".** The link was built as route() plus "?term=" + term — but both
are QUERY parameters on this route, so route() had already opened the
query string and the result was `?plan=team?term=monthly`. The package
arrived as the whole string "team?term=monthly", matched nothing in the
catalogue, and the page said so politely while giving no hint why. The
address now comes from route() in one piece with a placeholder swapped
into it, and a test asserts every checkout link on the page contains
exactly one question mark.

**The saving moved the buttons it belonged to.** It hung under the switch
and was hidden on the yearly view, so choosing a term shifted the very
control that chooses it. It sits above now and never disappears: one line,
two wordings — an offer while it is an offer, a statement once it is
taken. Same on the public sheet, where its vanishing re-centred the row.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-07-30 16:05:56 +02:00
nexxo 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>
2026-07-30 16:00:08 +02:00
nexxo 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>
2026-07-30 15:44:35 +02:00
nexxo 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>
2026-07-30 15:22:35 +02:00