Beim Kuendigen wird gefragt, ob der Kunde seine Daten will

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 <noreply@anthropic.com>
claude/nice-moser-521659
nexxo 2026-08-04 08:29:34 +02:00
parent 5dd75dac86
commit 7d041db5e5
8 changed files with 173 additions and 0 deletions

View File

@ -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.
*

View File

@ -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.',

View File

@ -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.',

View File

@ -56,6 +56,29 @@
@error('note')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
{{-- 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. --}}
<div class="mt-3">
<label class="text-sm text-body">{{ __('settings.cancel_export_label') }}</label>
<p class="mt-1 text-xs leading-relaxed text-muted">{{ __('settings.cancel_export_hint') }}</p>
<div class="mt-1.5 flex flex-wrap gap-2">
<label class="flex cursor-pointer items-center gap-2 rounded border border-line-strong bg-surface px-3 py-2 text-sm text-ink transition has-[:checked]:border-ink">
<input type="radio" wire:model="exportWish" value="1" class="size-4 shrink-0 border-line text-ink" />
{{ __('settings.cancel_export_yes') }}
</label>
<label class="flex cursor-pointer items-center gap-2 rounded border border-line-strong bg-surface px-3 py-2 text-sm text-ink transition has-[:checked]:border-ink">
<input type="radio" wire:model="exportWish" value="0" class="size-4 shrink-0 border-line text-ink" />
{{ __('settings.cancel_export_no') }}
</label>
</div>
@error('exportWish')<p class="mt-1 text-xs text-danger">{{ $message }}</p>@enderror
</div>
<div class="mt-4">
<label class="text-sm text-body">{{ __('settings.cancel_confirm_label', ['name' => $subdomain]) }}</label>
<input type="text" wire:model="confirmName" class="mt-1.5 w-full rounded border border-line-strong bg-surface px-3 py-2 text-sm text-ink" />

View File

@ -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']);

View File

@ -0,0 +1,99 @@
<?php // tests/Feature/Cancellation/CancelAsksAboutExportTest.php
use App\Livewire\ConfirmCancelPackage;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\User;
use Livewire\Livewire;
/**
* Abweichung vom Zettel: keine der drei Pruefungen unten setzt im Original
* `reason`. Im Bestand verlangt ConfirmCancelPackage::cancelPackage() aber
* schon laenger einen gueltigen Grund (Subscription::CANCEL_REASONS) und
* bricht sonst VOR jeder Schreibaktion mit einem eigenen Fehler ab das war
* so, bevor diese Aufgabe begann, und diese Aufgabe aendert daran nichts. Ohne
* `->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');
});

View File

@ -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();

View File

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