From 7d041db5e575b62bf40959e44aeb5098abbcaf4b Mon Sep 17 00:00:00 2001 From: nexxo Date: Tue, 4 Aug 2026 08:29:34 +0200 Subject: [PATCH] Beim Kuendigen wird gefragt, ob der Kunde seine Daten will MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Der Angelpunkt des ganzen Vorhabens: wer keinen Export braucht, loest keine Arbeit aus und wartet auf nichts. Zwei Auswahlfelder und keine Checkbox — eine Checkbox kennt keinen dritten Zustand, und "nicht angekreuzt" waere von "nein" nicht zu unterscheiden. Die Frage ist nicht ueberspringbar (#[Validate('required|boolean')], vor Stripe geprueft wie jede andere Vorbedingung hier). Das macht drei bestehende Erfolgspfad-Pruefungen zu ConfirmCancelPackage neu pflichtig in einem Feld, das sie vorher nicht kannten — PackageCancellationTest, SettingsTest und EndInstanceServiceTest setzen deshalb jetzt zusaetzlich exportWish, ohne dass sich an ihren eigentlichen Zusicherungen etwas aendert. Co-Authored-By: Claude Opus 5 --- app/Livewire/ConfirmCancelPackage.php | 34 +++++++ lang/de/settings.php | 5 + lang/en/settings.php | 5 + .../livewire/confirm-cancel-package.blade.php | 23 +++++ .../Billing/PackageCancellationTest.php | 4 + .../CancelAsksAboutExportTest.php | 99 +++++++++++++++++++ .../Provisioning/EndInstanceServiceTest.php | 1 + tests/Feature/SettingsTest.php | 2 + 8 files changed, 173 insertions(+) create mode 100644 tests/Feature/Cancellation/CancelAsksAboutExportTest.php diff --git a/app/Livewire/ConfirmCancelPackage.php b/app/Livewire/ConfirmCancelPackage.php index 3d2a543..09cb537 100644 --- a/app/Livewire/ConfirmCancelPackage.php +++ b/app/Livewire/ConfirmCancelPackage.php @@ -10,6 +10,7 @@ use App\Services\Billing\WithdrawalRight; use App\Services\Stripe\StripeClient; use Illuminate\Support\Carbon; use Illuminate\Support\Facades\Log; +use Livewire\Attributes\Validate; use LivewireUI\Modal\ModalComponent; use Throwable; @@ -53,6 +54,18 @@ class ConfirmCancelPackage extends ModalComponent /** What they wanted to say about it — required when the reason is "other". */ public string $note = ''; + /** + * Ob der Kunde zum Laufzeitende einen Datenexport will. Drei Zustaende, + * nicht zwei: `null` heisst unbeantwortet und ist beim Kuendigen nicht + * zulaessig — diese Frage ist der Angelpunkt des ganzen Vorhabens + * (Erinnerungen, Export-Vorbereitung) und darf nicht uebersprungen + * werden. `boolean` laesst `true` UND `false` durch: ein bewusstes Nein + * ist eine ebenso gueltige Antwort wie ein Ja und darf nicht wie eine + * fehlende Antwort behandelt werden. + */ + #[Validate('required|boolean')] + public ?bool $exportWish = null; + public function cancelPackage() { $customer = $this->customer(); @@ -91,6 +104,13 @@ class ConfirmCancelPackage extends ModalComponent return; } + // Nicht ueberspringbar, aus demselben Grund wie der Kuendigungsgrund + // oben: die Kuendigung ist der einzige Moment, in dem der Kunde + // ohnehin ueber seine Daten nachdenkt, und danach fragt niemand mehr. + // Vor Stripe geprueft, wie alles andere hier — ein abgebrochener + // Versuch soll nichts anstossen, weder bei Stripe noch bei uns. + $this->validate(); + $contract = $this->contractFor($customer, $instance); if (! $this->stopBilling($contract)) { @@ -103,6 +123,7 @@ class ConfirmCancelPackage extends ModalComponent 'status' => 'cancellation_scheduled', 'cancel_requested_at' => now(), 'service_ends_at' => $this->currentPeriodEnd($instance, $contract), + 'export_wish' => $this->exportWish, ]); // The contract carries the request too. Without it nothing in the billing @@ -118,6 +139,19 @@ class ConfirmCancelPackage extends ModalComponent return $this->redirectRoute('settings', navigate: true); } + /** + * Eigene Meldung fuer die Exportfrage. Im Bestand liegt keine + * `lang/de/validation.php` — ohne Ueberschreibung waere die eingebaute + * Laravel-Meldung die einzige englische Zeile in einem sonst + * durchgehend deutschen Dialog. + */ + protected function messages(): array + { + return [ + 'exportWish.required' => __('settings.cancel_export_required'), + ]; + } + /** * Ask Stripe to raise no further cycle for this contract. * diff --git a/lang/de/settings.php b/lang/de/settings.php index c1136a4..53592cf 100644 --- a/lang/de/settings.php +++ b/lang/de/settings.php @@ -81,6 +81,11 @@ return [ 'support' => 'Unzufrieden mit der Betreuung', 'other' => 'Sonstiges', ], + '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.', + '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', 'cancel_mismatch' => 'Die Eingabe stimmt nicht mit Ihrer Cloud-Adresse überein.', diff --git a/lang/en/settings.php b/lang/en/settings.php index 25f2292..3f4e98c 100644 --- a/lang/en/settings.php +++ b/lang/en/settings.php @@ -81,6 +81,11 @@ return [ 'support' => 'Unhappy with the support', 'other' => 'Other', ], + '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.', + 'cancel_export_required' => 'Please answer this question — we cannot cancel without it.', 'cancel_confirm_label' => 'Type “:name” to confirm:', 'cancel_confirm' => 'Cancel for good', 'cancel_mismatch' => 'That does not match your cloud address.', diff --git a/resources/views/livewire/confirm-cancel-package.blade.php b/resources/views/livewire/confirm-cancel-package.blade.php index 4158b72..935a2cc 100644 --- a/resources/views/livewire/confirm-cancel-package.blade.php +++ b/resources/views/livewire/confirm-cancel-package.blade.php @@ -56,6 +56,29 @@ @error('note')

{{ $message }}

@enderror + {{-- Zwei Auswahlfelder statt einer Checkbox (R-Vorgabe dieser Aufgabe): eine + Checkbox kennt keinen dritten Zustand, und "nicht angekreuzt" waere von + einem bewussten Nein nicht zu unterscheiden — genau diese Unterscheidung + entscheidet, ob wir spaeter Erinnerungen schicken und einen Export + vorbereiten. Radio-Werte "1"/"0" statt leerer Wert, damit PHP sie beim + Setzen eindeutig nach true/false wandelt und keiner der beiden mit dem + unbeantworteten null verwechselt werden kann. --}} +
+ +

{{ __('settings.cancel_export_hint') }}

+
+ + +
+ @error('exportWish')

{{ $message }}

@enderror +
+
diff --git a/tests/Feature/Billing/PackageCancellationTest.php b/tests/Feature/Billing/PackageCancellationTest.php index 277901e..74b73d9 100644 --- a/tests/Feature/Billing/PackageCancellationTest.php +++ b/tests/Feature/Billing/PackageCancellationTest.php @@ -114,6 +114,7 @@ it('tells Stripe to stop at the end of the term and keeps a yearly customer thei Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', 'berger') ->set('reason', 'no_longer_needed') + ->set('exportWish', true) ->call('cancelPackage') ->assertHasNoErrors(); @@ -149,6 +150,7 @@ it('keeps a monthly customer their month and no longer', function () { Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', 'berger') ->set('reason', 'no_longer_needed') + ->set('exportWish', true) ->call('cancelPackage') ->assertHasNoErrors(); @@ -174,6 +176,7 @@ it('cancels a granted package cleanly and asks Stripe nothing at all', function Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', 'berger') ->set('reason', 'no_longer_needed') + ->set('exportWish', true) ->call('cancelPackage') ->assertHasNoErrors(); @@ -192,6 +195,7 @@ it('records nothing at all when Stripe cannot be told to stop', function () { Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', 'berger') ->set('reason', 'no_longer_needed') + ->set('exportWish', true) ->call('cancelPackage') ->assertHasErrors(['confirmName']); diff --git a/tests/Feature/Cancellation/CancelAsksAboutExportTest.php b/tests/Feature/Cancellation/CancelAsksAboutExportTest.php new file mode 100644 index 0000000..6b187ab --- /dev/null +++ b/tests/Feature/Cancellation/CancelAsksAboutExportTest.php @@ -0,0 +1,99 @@ +set('reason', ...)` wuerde jede der drei Pruefungen an dieser + * vorgelagerten Stelle scheitern, nicht an der Exportfrage, die hier geprueft + * werden soll. Alle drei setzen deshalb zusaetzlich einen gueltigen Grund. + */ + +/** + * Ein aktives Paket, dessen Besitzer den Kuendigungsdialog erreichen kann — + * nach dem Muster von settingsSetup() (tests/Feature/SettingsTest.php) und + * cancellablePackage() (tests/Feature/Billing/PackageCancellationTest.php): + * Nutzer, Kunde, Bestellung, aktive Instanz. Bewusst OHNE eigenen + * Subscription-Datensatz: ConfirmCancelPackage::contractFor() findet dann + * keinen Vertrag, und stopBilling(null) gibt ungefragt true zurueck (derselbe + * Weg wie bei einem geschenkten Paket) — Stripe bleibt aussen vor, weil es in + * dieser Aufgabe ausschliesslich um die Exportfrage geht. + * + * @return array{0: User, 1: Instance} + */ +function kuendbareInstanz(): array +{ + $user = User::factory()->create(['email' => 'kuendigt@example.test', 'email_verified_at' => now()]); + + $customer = Customer::factory()->create([ + 'email' => 'kuendigt@example.test', + 'user_id' => $user->id, + 'status' => 'active', + ]); + + $order = Order::factory()->create(['customer_id' => $customer->id, 'plan' => 'team']); + + $instance = Instance::factory()->create([ + 'customer_id' => $customer->id, + 'order_id' => $order->id, + 'plan' => 'team', + 'status' => 'active', + 'subdomain' => 'kuendigt', + ]); + + return [$user, $instance->refresh()]; +} + +it('schreibt die Antwort des Kunden an die Instanz', function () { + [$user, $instance] = kuendbareInstanz(); + + Livewire::actingAs($user)->test(ConfirmCancelPackage::class) + ->set('confirmName', $instance->subdomain) + ->set('reason', 'no_longer_needed') + ->set('exportWish', true) + ->call('cancelPackage') + ->assertHasNoErrors(); + + expect($instance->fresh()->export_wish)->toBeTrue(); +}); + +it('nimmt auch ein Nein als Antwort — und nicht als fehlende Antwort', function () { + // Das ist die Zusicherung, um die es geht: wer bewusst nein sagt, soll + // nicht wie jemand behandelt werden, den niemand gefragt hat. Nur das + // erspart ihm die Erinnerungen und uns die Arbeit. + [$user, $instance] = kuendbareInstanz(); + + Livewire::actingAs($user)->test(ConfirmCancelPackage::class) + ->set('confirmName', $instance->subdomain) + ->set('reason', 'no_longer_needed') + ->set('exportWish', false) + ->call('cancelPackage') + ->assertHasNoErrors(); + + expect($instance->fresh()->export_wish)->toBeFalse() + ->and($instance->fresh()->export_wish)->not->toBeNull(); +}); + +it('kuendigt nicht ohne eine Antwort auf die Frage', function () { + // Eine Frage, die man ueberspringen kann, ist keine Frage — und die + // Kuendigung ist der einzige Moment, in dem der Kunde ohnehin ueber seine + // Daten nachdenkt. + [$user, $instance] = kuendbareInstanz(); + + Livewire::actingAs($user)->test(ConfirmCancelPackage::class) + ->set('confirmName', $instance->subdomain) + ->set('reason', 'no_longer_needed') + ->call('cancelPackage') + ->assertHasErrors('exportWish'); + + expect($instance->fresh()->status)->toBe('active'); +}); diff --git a/tests/Feature/Provisioning/EndInstanceServiceTest.php b/tests/Feature/Provisioning/EndInstanceServiceTest.php index 79182bc..8989fc8 100644 --- a/tests/Feature/Provisioning/EndInstanceServiceTest.php +++ b/tests/Feature/Provisioning/EndInstanceServiceTest.php @@ -187,6 +187,7 @@ it('carries the whole way from the customer cancelling to the address going away Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', $instance->subdomain) ->set('reason', 'no_longer_needed') + ->set('exportWish', true) ->call('cancelPackage'); $instance->refresh(); diff --git a/tests/Feature/SettingsTest.php b/tests/Feature/SettingsTest.php index 5d9a557..eaaa50e 100644 --- a/tests/Feature/SettingsTest.php +++ b/tests/Feature/SettingsTest.php @@ -193,6 +193,7 @@ it('schedules package cancellation only with the correct typed confirmation', fu Livewire::actingAs($user)->test(ConfirmCancelPackage::class) ->set('confirmName', 'acme') ->set('reason', 'too_expensive') + ->set('exportWish', true) ->call('cancelPackage'); $instance->refresh(); @@ -321,6 +322,7 @@ it('records why, and refuses "other" with nothing beside it', function () { ->set('confirmName', $instance->subdomain) ->set('reason', 'other') ->set('note', 'Wir ziehen intern um.') + ->set('exportWish', false) ->call('cancelPackage'); expect($contract->fresh()->cancel_reason)->toBe('other')