diff --git a/app/Livewire/Dashboard.php b/app/Livewire/Dashboard.php index 959a046..99ce74d 100644 --- a/app/Livewire/Dashboard.php +++ b/app/Livewire/Dashboard.php @@ -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; } /** diff --git a/lang/de/settings.php b/lang/de/settings.php index 64bd633..8038441 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -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', diff --git a/lang/en/settings.php b/lang/en/settings.php index bcdeab6..a78e6be 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -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', diff --git a/tests/Feature/Cancellation/CancelAsksAboutExportTest.php b/tests/Feature/Cancellation/CancelAsksAboutExportTest.php index 27d6fe4..f14fe10 100644 --- a/tests/Feature/Cancellation/CancelAsksAboutExportTest.php +++ b/tests/Feature/Cancellation/CancelAsksAboutExportTest.php @@ -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 diff --git a/tests/Feature/Cancellation/DashboardCountdownTest.php b/tests/Feature/Cancellation/DashboardCountdownTest.php index 70c9051..63ee6a1 100644 --- a/tests/Feature/Cancellation/DashboardCountdownTest.php +++ b/tests/Feature/Cancellation/DashboardCountdownTest.php @@ -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')); +});