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
parent
5dd75dac86
commit
7d041db5e5
|
|
@ -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.
|
||||
*
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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" />
|
||||
|
|
|
|||
|
|
@ -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']);
|
||||
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
});
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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')
|
||||
|
|
|
|||
Loading…
Reference in New Issue