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')