Zweite Fix-Welle: kein „beendet" ueber einem laufenden Aufbau
tests / pest (push) Waiting to run Details
tests / assets (push) Waiting to run Details
tests / release (push) Blocked by required conditions Details

Zwei Nachwirkungen der ersten Fix-Welle (c9fef59).

1 — Die Regression, die sie selbst gesetzt hat. `endedAt()` fragte nur
„laeuft gerade nichts?" und nahm dann die juengste beendete Instanz. „Nichts
in Betrieb" ist aber nicht dasselbe wie „mit uns fertig": eine frisch
bestellte Instanz entsteht als `reserving` (ReserveResources) und bleibt das
den ganzen Bereitstellungslauf lang, `failed` (Order::markFailed) steht, bis
ein Betreiber wiederholt — beide fehlen in der Betriebs-Abfrage. Ein
wiederkehrender Kunde, der eben bezahlt hatte, las deshalb „Ihre Cloud ist
beendet." samt „Neues Paket buchen", direkt ueber dem Streifen, der seinen
laufenden Aufbau zeigte.

Jetzt muss die JUENGSTE Instanz des Kunden die beendete sein. Alle sechs
Instanzzustaende nachgeprueft (reserving, provisioning, active, failed,
cancellation_scheduled, ended; `suspended` ist seit 2026_07_31_160000 keiner
mehr, sondern eine eigene Marke) — jeder ausser `ended` ganz oben heisst: es
geschieht etwas Neueres.

Dazu eine zweite Bedingung, die die erste allein nicht traegt:
ReserveResources parkt eine bezahlte Bestellung ohne Platz und legt dabei GAR
KEINE Instanz an, bis zu vierzehn Tage. Die juengste Instanz ist in diesem
Fenster weiterhin die alte, beendete. Der Kasten bleibt deshalb weg, solange
fuer den Kunden ein Aufbau laeuft — dieselbe Bedingung, unter der
CustomerProvisioning den Fortschrittsstreifen zeigt. Zwei Bauteile auf einer
Seite duerfen einander nicht widersprechen.

2 — Das Versprechen stand noch dort, wo entschieden wird. Die erste Welle hat
drei von vier Stellen zurueckgenommen; die vierte war der Kuendigungsdialog
selbst. Der Kunde las dort einen Liefergegenstand („einen Link zum
Herunterladen", „erhalten Sie ... einen vollstaendigen Datenexport"), klickte
einmal auf Ja — und die Vertragsseite sagte danach nur noch „Ihr Wunsch ist
vermerkt, wir melden uns". Beide Saetze sind jetzt auf dasselbe Mass gebracht:
kein Liefergegenstand, keine Frist, kein Link, solange der Export nicht gebaut
ist. Der wahre Teil bleibt wortgleich stehen — ab dem Laufzeitende ist die
Cloud nicht mehr erreichbar, bis dahin selbst sichern.

lang/{de,en}/order.php bleibt absichtlich unveraendert: dieser Satz steht im
Verkauf und beschreibt, was das Produkt zusagt. Ihn zu streichen aendert das
Angebot und ist eine Entscheidung des Eigentuemers. Begruendung im Bericht.

Zu beiden Befunden wurde der Fix zurueckgedreht und die neue Zusicherung rot
gesehen (3 rot beim Waechter, 2 rot bei den Sprachdateien), dazu die
Gegenprobe mit einem zu breiten Waechter (1 rot). Ganze Suite: 2842 gruen,
0 rot.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 10:25:01 +02:00
parent e68113dd02
commit b3ddc44256
5 changed files with 213 additions and 14 deletions

View File

@ -8,6 +8,8 @@ use App\Models\Datacenter;
use App\Models\Instance;
use App\Models\InstanceMetric;
use App\Models\MaintenanceWindow;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\Seat;
use App\Models\Subscription;
use App\Services\Traffic\TrafficMeter;
@ -93,19 +95,30 @@ class Dashboard extends Component
* Wann die Laufzeit dieses Kunden endete oder null.
*
* Die Abfrage oben holt nur, was in Betrieb ist; eine `ended`-Instanz
* faellt heraus, und der Kunde landete dadurch in demselben Zweig wie
* fällt heraus, und der Kunde landete dadurch in demselben Zweig wie
* jemand, der noch nie etwas bestellt hat. Er las „Ihre Cloud wird
* eingerichtet." und bekam einen Knopf „Paket buchen" am Tag, an dem
* ihm EndInstanceService die Adresse eingezogen hat.
*
* Nur gefragt, wenn nichts mehr laeuft: wer neben der abgelaufenen
* Nur gefragt, wenn nichts mehr läuft: wer neben der abgelaufenen
* Instanz schon wieder eine aktive hat, ist kein beendeter Kunde, und
* seine Seite soll von der alten nichts erzaehlen.
* seine Seite soll von der alten nichts erzählen.
*
* `service_ends_at` ist dabei nie null `ended` wird ausschliesslich
* ueber EndInstanceService::hasEnded() erreicht, und die verlangt das
* Die JÜNGSTE Instanz muss die beendete sein, nicht irgendeine beendete.
* „Nichts in Betrieb" ist nämlich nicht dasselbe wie „mit uns fertig":
* eine frisch bestellte Instanz entsteht als `reserving` (ReserveResources)
* und bleibt das den ganzen Bereitstellungslauf lang, und `failed`
* (Order::markFailed) steht, bis ein Betreiber wiederholt. Beide fehlen in
* der Liste oben der wiederkehrende Kunde, der eben bezahlt hat, las
* damit „Ihre Cloud ist beendet." samt „Neues Paket buchen", direkt über
* dem Streifen, der seinen laufenden Aufbau zeigt. Jeder Zustand außer
* `ended` ganz oben heißt: es geschieht etwas Neueres, und die alte
* Laufzeit ist nicht mehr die Nachricht dieser Seite.
*
* `service_ends_at` ist dabei nie null `ended` wird ausschließlich
* über EndInstanceService::hasEnded() erreicht, und die verlangt das
* Datum. Trotzdem hier verlangt, statt darauf zu vertrauen: ein Satz mit
* „endete am " waere schlimmer als der Zweig darunter.
* „endete am " wäre schlimmer als der Zweig darunter.
*/
private function endedAt(?Customer $customer, ?Instance $instance): ?Carbon
{
@ -113,11 +126,45 @@ class Dashboard extends Component
return null;
}
return $customer->instances()
->where('status', 'ended')
->whereNotNull('service_ends_at')
$latest = $customer->instances()->latest('id')->first();
if ($latest === null || $latest->status !== 'ended' || $latest->service_ends_at === null) {
return null;
}
if ($this->buildInFlight($customer)) {
return null;
}
return $latest->service_ends_at;
}
/**
* Läuft für diesen Kunden gerade ein Aufbau?
*
* Die Instanzprüfung oben trägt den Fall nicht allein: ReserveResources
* parkt eine bezahlte Bestellung, für die kein Host Platz hat, und legt
* dabei GAR KEINE Instanz an bis zu vierzehn Tage lang. Die jüngste
* Instanz des Kunden ist in diesem Fenster weiterhin die alte, beendete,
* und ohne diese zweite Frage stünde der Beendet-Kasten tagelang über
* einem Streifen, der auf eine Maschine wartet.
*
* Bewusst dieselbe Bedingung, unter der CustomerProvisioning den Streifen
* überhaupt zeigt (jüngster Kundenlauf, noch nicht abgeschlossen) auch
* ein fehlgeschlagener Lauf gehört dazu: er endet mit einem Betreiber,
* der wiederholt, nicht mit einem Kunden, dessen Paket vorbei ist. Zwei
* Bauteile auf einer Seite dürfen einander nicht widersprechen.
*/
private function buildInFlight(Customer $customer): bool
{
$run = ProvisioningRun::query()
->where('subject_type', Order::class)
->where('pipeline', 'customer')
->whereIn('subject_id', $customer->orders()->select('id'))
->latest('id')
->first()?->service_ends_at;
->first();
return $run !== null && $run->status !== ProvisioningRun::STATUS_COMPLETED;
}
/**

View File

@ -72,7 +72,13 @@ return [
'cancel_title' => 'Paket kündigen?',
'cancel_body' => 'Ihr Paket wird zum Ende der Abrechnungsperiode gekündigt.',
'cancel_point_term' => 'Wirksam zum Ende der Laufzeit — bis dahin bleibt alles verfügbar.',
'cancel_point_export' => 'Zum Laufzeitende erhalten Sie auf Wunsch einen vollständigen Datenexport.',
// Kein Liefergegenstand und keine Frist, solange der Export nicht gebaut
// ist: dieser Punkt steht im Kündigungsdialog, drei Zeilen über der Frage
// selbst, und war damit das Versprechen an der wirksamsten Stelle — im
// Moment der Entscheidung. Die Vertragsseite sagt danach nur noch, dass
// der Wunsch vermerkt ist und sich jemand meldet (cancel_scheduled_body_yes);
// beide Sätze sind jetzt auf dasselbe Maß gebracht.
'cancel_point_export' => 'Einen Datenexport zum Laufzeitende können Sie sich wünschen — wir melden uns dann dazu bei Ihnen.',
'cancel_point_irreversible' => 'Die Kündigung ist nach Bestätigung verbindlich.',
'cancel_reason_label' => 'Warum kündigen Sie?',
'cancel_reason_choose' => 'Bitte wählen',
@ -93,7 +99,11 @@ return [
'cancel_export_label' => 'Möchten Sie einen Datenexport?',
'cancel_export_yes' => 'Ja, ich möchte einen Export',
'cancel_export_no' => 'Nein, ich brauche keinen Export',
'cancel_export_hint' => 'Bei Ja senden wir Ihnen zum Laufzeitende einen Link zum Herunterladen. Bei Nein bereiten wir nichts vor. So oder so: Ab dem Laufzeitende ist Ihre Cloud für Sie nicht mehr erreichbar — sichern Sie sich bis dahin selbst, was Sie brauchen.',
// Siehe cancel_point_export: aufgenommen wird der Wunsch, geliefert wird
// nichts Zugesagtes. Der zweite Teil des Satzes bleibt wortgleich stehen —
// er ist wahr, und er ist der einzige Rat, der den Kunden hier wirklich
// schützt.
'cancel_export_hint' => 'Bei Ja nehmen wir Ihren Wunsch auf und melden uns dazu bei Ihnen. Bei Nein bereiten wir nichts vor. So oder so: Ab dem Laufzeitende ist Ihre Cloud für Sie nicht mehr erreichbar — sichern Sie sich bis dahin selbst, was Sie brauchen.',
'cancel_export_required' => 'Bitte beantworten Sie diese Frage — ohne Antwort können wir nicht kündigen.',
'cancel_confirm_label' => 'Zum Bestätigen „:name" eingeben:',
'cancel_confirm' => 'Verbindlich kündigen',

View File

@ -72,7 +72,13 @@ return [
'cancel_title' => 'Cancel package?',
'cancel_body' => 'Your package will be cancelled at the end of the billing period.',
'cancel_point_term' => 'Effective at the end of the term — everything stays available until then.',
'cancel_point_export' => 'At the end of the term you receive a full data export, on request.',
// Kein Liefergegenstand und keine Frist, solange der Export nicht gebaut
// ist: dieser Punkt steht im Kündigungsdialog, drei Zeilen über der Frage
// selbst, und war damit das Versprechen an der wirksamsten Stelle — im
// Moment der Entscheidung. Die Vertragsseite sagt danach nur noch, dass
// der Wunsch vermerkt ist und sich jemand meldet (cancel_scheduled_body_yes);
// beide Sätze sind jetzt auf dasselbe Maß gebracht.
'cancel_point_export' => 'You can ask for a data export at the end of the term — we will then come back to you about it.',
'cancel_point_irreversible' => 'Once confirmed, the cancellation is binding.',
'cancel_reason_label' => 'Why are you cancelling?',
'cancel_reason_choose' => 'Please choose',
@ -93,7 +99,11 @@ return [
'cancel_export_label' => 'Would you like a data export?',
'cancel_export_yes' => 'Yes, I would like an export',
'cancel_export_no' => 'No, I do not need an export',
'cancel_export_hint' => 'If yes, we will send you a download link at the end of the term. If no, we prepare nothing. Either way: from the end of the term your cloud is no longer reachable for you — save what you need yourself before then.',
// Siehe cancel_point_export: aufgenommen wird der Wunsch, geliefert wird
// nichts Zugesagtes. Der zweite Teil des Satzes bleibt wortgleich stehen —
// er ist wahr, und er ist der einzige Rat, der den Kunden hier wirklich
// schützt.
'cancel_export_hint' => 'If yes, we note your request and come back to you about it. If no, we prepare nothing. Either way: from the end of the term your cloud is no longer reachable for you — save what you need yourself before then.',
'cancel_export_required' => 'Please answer this question — we cannot cancel without it.',
'cancel_confirm_label' => 'Type “:name” to confirm:',
'cancel_confirm' => 'Cancel for good',

View File

@ -83,6 +83,35 @@ it('nimmt auch ein Nein als Antwort — und nicht als fehlende Antwort', functio
->and($instance->fresh()->export_wish)->not->toBeNull();
});
it('verspricht im Kündigungsdialog keinen Export, den es noch nicht gibt', function (string $sprache, array $verboten) {
// Die Fix-Welle hat drei von vier Stellen zurückgenommen; diese vierte ist
// die wirksamste, weil sie im Moment der Entscheidung steht. Der Kunde las
// hier einen Liefergegenstand („einen Link zum Herunterladen"), klickte
// einmal auf Ja — und die Vertragsseite sagte ihm danach nur noch, sein
// Wunsch sei vermerkt. Geprüft in beiden Sprachen und am gerenderten
// Dialog, nicht an der Sprachdatei: der Widerspruch entstand dadurch, dass
// ein Satz irgendwo stehen blieb, und genau das fällt nur auf, wenn die
// Prüfung dorthin schaut, wo der Kunde liest.
app()->setLocale($sprache);
[$user] = kuendbareInstanz();
$dialog = Livewire::actingAs($user)->test(ConfirmCancelPackage::class);
foreach ($verboten as $versprechen) {
$dialog->assertDontSee($versprechen);
}
// Und der Satz, der stattdessen dort steht, muss auch wirklich ankommen —
// sonst wäre die Prüfung oben schon dadurch grün, dass der Dialog gar
// nichts über den Export sagt.
$dialog->assertSee(__('settings.cancel_export_hint'))
->assertSee(__('settings.cancel_point_export'));
})->with([
['de', ['Link zum Herunterladen', 'erhalten Sie auf Wunsch einen vollständigen Datenexport']],
['en', ['download link', 'you receive a full data export']],
]);
it('kündigt nicht ohne eine Antwort auf die Frage', function () {
// Eine Frage, die man überspringen kann, ist keine Frage — und die
// Kündigung ist der einzige Moment, in dem der Kunde ohnehin über seine

View File

@ -4,6 +4,7 @@ use App\Livewire\Dashboard;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\ProvisioningRun;
use App\Models\User;
use Livewire\Livewire;
@ -237,3 +238,105 @@ it('laesst die Antwort nach dem Laufzeitende nicht mehr aendern', function () {
expect($instance->fresh()->export_wish)->toBeFalse();
});
/**
* Ein wiederkehrender Kunde: die alte Instanz ist beendet, die neue eben
* bestellt und bezahlt. Der Aufbau legt sie als `reserving` an
* (App\Provisioning\Steps\Customer\ReserveResources) und lässt sie den ganzen
* Lauf lang so stehen; scheitert er, setzt Order::markFailed() sie auf
* `failed`, bis ein Betreiber wiederholt. Beide Zustände fehlen in der
* Betriebs-Abfrage von Dashboard::render() genau deshalb braucht es den
* Wächter in endedAt().
*
* @return array{0: User, 1: Instance} Nutzer und die NEUE Instanz
*/
function wiederkehrenderKunde(string $neuerStatus): array
{
$user = User::factory()->create(['email' => 'wiederkehrer@example.test', 'email_verified_at' => now()]);
$customer = Customer::factory()->create([
'email' => 'wiederkehrer@example.test',
'user_id' => $user->id,
'status' => 'active',
]);
$alteBestellung = Order::factory()->create(['customer_id' => $customer->id, 'plan' => 'team']);
Instance::factory()->create([
'customer_id' => $customer->id,
'order_id' => $alteBestellung->id,
'plan' => 'team',
'status' => 'ended',
'subdomain' => 'altbestand',
'cancel_requested_at' => now()->subMonths(2),
'service_ends_at' => now()->subMonth(),
]);
$neueBestellung = Order::factory()->create(['customer_id' => $customer->id, 'plan' => 'team']);
$neu = Instance::factory()->create([
'customer_id' => $customer->id,
'order_id' => $neueBestellung->id,
'plan' => 'team',
'status' => $neuerStatus,
'subdomain' => 'neubau',
]);
return [$user, $neu->refresh()];
}
it('sagt einem wiederkehrenden Kunden nicht, sein Paket sei beendet, während die neue Cloud gebaut wird', function (string $neuerStatus) {
// Der Rückschlag der Fix-Welle: endedAt() fragte nur „läuft gerade nichts?"
// und nicht „ist die beendete auch die jüngste?". Wer nach einer beendeten
// Laufzeit neu bestellt und bezahlt hat, las deshalb „Ihre Cloud ist
// beendet." samt „Neues Paket buchen" — direkt über dem Streifen, der
// seinen laufenden Aufbau zeigt. Beide Zustände geprüft, weil beide aus der
// Betriebs-Abfrage herausfallen und beide tagelang stehen können.
[$user] = wiederkehrenderKunde($neuerStatus);
Livewire::actingAs($user)->test(Dashboard::class)
->assertDontSee(__('dashboard.title_ended'))
->assertDontSee(__('dashboard.ended.label'))
->assertDontSee(__('dashboard.ended.order_again'));
})->with(['reserving', 'failed']);
it('sagt es auch dann nicht, wenn für die neue Bestellung noch gar keine Instanz angelegt ist', function () {
// Das zweite Fenster derselben Falschaussage, und das längere:
// ReserveResources parkt eine bezahlte Bestellung, für die kein Host Platz
// hat, und legt dabei KEINE Instanz an — bis zu vierzehn Tage. Die jüngste
// Instanz des Kunden ist in diesem Fenster weiterhin die alte, beendete,
// der Wächter über den Instanzzustand allein greift also nicht. Was ihn
// trägt, ist der laufende Aufbau selbst — dieselbe Bedingung, unter der
// CustomerProvisioning den Fortschrittsstreifen zeigt.
$user = User::factory()->create(['email' => 'geparkt@example.test', 'email_verified_at' => now()]);
$customer = Customer::factory()->create([
'email' => 'geparkt@example.test',
'user_id' => $user->id,
'status' => 'active',
]);
$alteBestellung = Order::factory()->create(['customer_id' => $customer->id, 'plan' => 'team']);
Instance::factory()->create([
'customer_id' => $customer->id,
'order_id' => $alteBestellung->id,
'plan' => 'team',
'status' => 'ended',
'subdomain' => 'altbestand',
'service_ends_at' => now()->subMonth(),
]);
$neueBestellung = Order::factory()->create(['customer_id' => $customer->id, 'plan' => 'team']);
ProvisioningRun::factory()->create([
'subject_type' => Order::class,
'subject_id' => $neueBestellung->id,
'pipeline' => 'customer',
'status' => ProvisioningRun::STATUS_WAITING,
]);
Livewire::actingAs($user)->test(Dashboard::class)
->assertDontSee(__('dashboard.title_ended'))
->assertDontSee(__('dashboard.ended.label'));
});