Ein Streifen im Dashboard sagt, wie lange der Zugang noch steht

Eine Mail sieben Tage vorher kann im Postfach untergehen; die eigene Uebersicht
oeffnet der Kunde ohnehin. Der Streifen nennt das Datum, die Restzeit und was
danach geschieht — dass der Zugang zur Cloud endet, nicht dass irgendetwas
geloescht wird. Das ist die Frist, die ihn betrifft.

Und er traegt die Antwort zum Export: wer liest, dass die Zeit laeuft, will es
sich im selben Atemzug anders ueberlegen koennen (Aufgabe 4) — bis zum
Laufzeitende, danach nicht mehr, geprueft direkt an der Methode und nicht nur
am Schalter.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
feat/versandtakt
nexxo 2026-08-04 09:29:16 +02:00
parent 5eef03d267
commit cfd1481797
5 changed files with 303 additions and 0 deletions

View File

@ -79,10 +79,86 @@ class Dashboard extends Component
// THIS customer above) rather than a fresh unscoped lookup, and // THIS customer above) rather than a fresh unscoped lookup, and
// never stored as a public property — see acknowledgeCredentials(). // never stored as a public property — see acknowledgeCredentials().
'credentials' => $this->credentials($instance, $domain), 'credentials' => $this->credentials($instance, $domain),
// Die Frist, die den Kunden betrifft — wie lange SEIN Zugang noch
// steht, nicht wann irgendetwas geloescht wird. Siehe ending().
'ending' => $this->ending($instance),
'asOf' => Carbon::now(), 'asOf' => Carbon::now(),
]); ]);
} }
/**
* Aendert die Antwort zum Export, solange sie noch etwas bedeutet: bis
* zum Laufzeitende. Wie acknowledgeCredentials() loest diese Methode
* Kunde UND Instanz selbst neu auf, statt einer vom Browser hydrierten
* Eigenschaft zu vertrauen sie ist ueber /livewire/update erreichbar,
* nicht nur ueber den Schalter im Streifen, und traegt deshalb dieselbe
* Bedingung wie ending() unten: verschwindet der Streifen, weil die
* Kuendigung nicht mehr aktiv ist oder das Laufzeitende vorbei ist, darf
* dieser Weg auch am Formular vorbei nichts mehr aendern.
*/
public function setExportWish(bool $wish): void
{
$customer = $this->requireCustomer();
if ($customer === null) {
return;
}
$instance = $customer->instances()
->where('status', 'cancellation_scheduled')
->where('service_ends_at', '>', now())
->latest('id')
->first();
if ($instance === null) {
return;
}
$instance->update(['export_wish' => $wish]);
}
/**
* Was der Streifen ueber die Restfrist braucht oder null, wenn er gar
* nicht erscheinen soll.
*
* Zeigt sich nur, wenn eine Kuendigung vorgemerkt ist UND das
* Laufzeitende noch in der Zukunft liegt. Der zweite Teil ist kein
* Sonderfall am Rand: der stuendliche Lauf, der eine abgelaufene Instanz
* auf `ended` stellt, laesst einen DNS-Fehler bewusst durch
* (EndInstanceService) und kann deshalb real tagelang hinterherhinken.
* Ohne diese Pruefung wuerde der Streifen in genau diesem Fenster eine
* abgelaufene Frist als Restzeit ausgeben der Fehler, den niemand
* meldet, weil er wie eine Kleinigkeit aussieht, und den jeder sieht.
*
* @return array{ends_at: Carbon, days: int, hours: int, show_days: bool}|null
*/
private function ending(?Instance $instance): ?array
{
if ($instance === null || $instance->status !== 'cancellation_scheduled' || $instance->service_ends_at === null) {
return null;
}
if (! $instance->service_ends_at->isFuture()) {
return null;
}
// Nie negativ, auch nicht auf dem Papier: geklemmt, obwohl die
// Pruefung oben das im Normalfall schon ausschliesst — die Zeit
// zwischen der Berechnung hier und der Anzeige im Browser soll sich
// nie als "-1 Sekunde" durchschlagen koennen.
$seconds = max(0, $instance->service_ends_at->getTimestamp() - Carbon::now()->getTimestamp());
return [
'ends_at' => $instance->service_ends_at,
// Abgerundet auf ganze Tage, solange mehr als ein Tag bleibt —
// "noch 12 Tage" liest sich, "noch 11 Tage 7 Stunden" nicht. Am
// letzten Tag zaehlt sie Stunden; das ist der Moment, in dem die
// Zahl wirklich zaehlt.
'days' => intdiv($seconds, 86400),
'hours' => intdiv($seconds, 3600),
'show_days' => $seconds > 86400,
];
}
/** /**
* Deletes the instance's stored admin password and stamps the * Deletes the instance's stored admin password and stamps the
* acknowledgement the customer has confirmed they noted it down. * acknowledgement the customer has confirmed they noted it down.

View File

@ -37,6 +37,21 @@ return [
'cancellation_scheduled' => 'Kündigung vorgemerkt', 'cancellation_scheduled' => 'Kündigung vorgemerkt',
], ],
// Der Streifen, der sagt, wie lange der Zugang noch steht — nicht, wann
// irgendetwas geloescht wird. Erscheint nur bei vorgemerkter Kuendigung
// mit Laufzeitende in der Zukunft (App\Livewire\Dashboard::ending()).
'ending' => [
'title' => 'Ihr Zugang endet',
'until' => 'Ab :date kommen Sie nicht mehr an Ihre Cloud.',
'remaining_days' => '{1} Noch 1 Tag.|[2,*] Noch :count Tage.',
'remaining_hours' => '{0} Weniger als eine Stunde.|{1} Noch 1 Stunde.|[2,*] Noch :count Stunden.',
'what_then' => 'Danach ist der Zugang zu Ihrer Nextcloud geschlossen. Laden Sie sich Ihre Daten bis dahin selbst herunter.',
'export_label' => 'Datenexport zum Laufzeitende',
'export_hint' => 'Sie können sich das bis zum Laufzeitende jederzeit anders überlegen.',
'export_yes' => 'Export gewünscht',
'export_no' => 'Kein Export gewünscht',
],
'no_instance_label' => 'Noch keine Instanz', 'no_instance_label' => 'Noch keine Instanz',
'no_instance_body' => 'Für dieses Konto läuft derzeit keine Cloud. Sobald eine Bestellung bezahlt ist, richten wir sie ein — dieser Bereich füllt sich dann von selbst.', 'no_instance_body' => 'Für dieses Konto läuft derzeit keine Cloud. Sobald eine Bestellung bezahlt ist, richten wir sie ein — dieser Bereich füllt sich dann von selbst.',
'no_instance_cta' => 'Paket wählen', 'no_instance_cta' => 'Paket wählen',

View File

@ -37,6 +37,21 @@ return [
'cancellation_scheduled' => 'Cancellation scheduled', 'cancellation_scheduled' => 'Cancellation scheduled',
], ],
// The strip that says how long access still stands — not when anything
// gets deleted. Only appears once cancellation is scheduled and the end
// of the term is still in the future (App\Livewire\Dashboard::ending()).
'ending' => [
'title' => 'Your access ends',
'until' => 'From :date you will no longer be able to reach your cloud.',
'remaining_days' => '{1} 1 day left.|[2,*] :count days left.',
'remaining_hours' => '{0} Less than an hour left.|{1} 1 hour left.|[2,*] :count hours left.',
'what_then' => 'After that your Nextcloud is closed off. Download your data yourself before then.',
'export_label' => 'Data export at the end of the term',
'export_hint' => 'You can change your mind about this at any time until the end of the term.',
'export_yes' => 'Export wanted',
'export_no' => 'No export wanted',
],
'no_instance_label' => 'No instance yet', 'no_instance_label' => 'No instance yet',
'no_instance_body' => 'No cloud is running for this account at the moment. As soon as an order is paid we set one up — this area then fills itself.', 'no_instance_body' => 'No cloud is running for this account at the moment. As soon as an order is paid we set one up — this area then fills itself.',
'no_instance_cta' => 'Choose a package', 'no_instance_cta' => 'Choose a package',

View File

@ -102,6 +102,45 @@
</section> </section>
@endif @endif
@if ($ending)
{{-- Die Frist, die den Kunden betrifft: wie lange SEIN Zugang noch
steht nicht, wann irgendetwas geloescht wird. Dieselbe
Warnfarbe und Bauform wie der Zugangsdaten-Kasten darueber, an
derselben Stelle im Fluss: eine Sprache fuer Dringlichkeit auf
dieser Seite, nicht zwei. --}}
<section class="rounded-lg border border-warning-border bg-warning-bg p-6 shadow-xs animate-rise">
<div class="flex gap-4">
<span class="grid size-10 shrink-0 place-items-center rounded-lg bg-warning-bg text-warning" aria-hidden="true">
<x-ui.icon name="calendar" class="size-5" />
</span>
<div class="min-w-0 flex-1">
<h2 class="font-semibold text-warning">{{ __('dashboard.ending.title') }}</h2>
<p class="mt-1 max-w-lg text-sm text-warning">
{{ __('dashboard.ending.until', ['date' => $ending['ends_at']->local()->locale($locale)->isoFormat('LL')]) }}
{{ $ending['show_days']
? trans_choice('dashboard.ending.remaining_days', $ending['days'])
: trans_choice('dashboard.ending.remaining_hours', $ending['hours']) }}
</p>
<p class="mt-1 max-w-lg text-sm text-warning">{{ __('dashboard.ending.what_then') }}</p>
{{-- Aufgabe 4: dieselbe Frage wie beim Kuendigen, hier mit
der Moeglichkeit, sie sich anders zu ueberlegen bis
zum Laufzeitende (setExportWish() prueft das selbst
nach). --}}
<div class="mt-4 max-w-sm rounded-lg border border-warning-border bg-surface px-3 py-2.5">
<x-ui.switch name="export_wish"
wire:click="setExportWish({{ $instance->export_wish ? 'false' : 'true' }})"
:checked="(bool) $instance->export_wish"
:label="__('dashboard.ending.export_label')"
:hint="__('dashboard.ending.export_hint')"
:on="__('dashboard.ending.export_yes')"
:off="__('dashboard.ending.export_no')" />
</div>
</div>
</div>
</section>
@endif
@if ($instance === null) @if ($instance === null)
{{-- Said plainly rather than papered over with an empty record: a page {{-- Said plainly rather than papered over with an empty record: a page
of dashes reads as broken, and "we are still setting it up" and of dashes reads as broken, and "we are still setting it up" and

View File

@ -0,0 +1,158 @@
<?php // tests/Feature/Cancellation/DashboardCountdownTest.php
use App\Livewire\Dashboard;
use App\Models\Customer;
use App\Models\Instance;
use App\Models\Order;
use App\Models\User;
use Livewire\Livewire;
/**
* Ein Kunde mit einer gekündigten Instanz nach dem Muster von
* kuendbareInstanz() (tests/Feature/Cancellation/CancelAsksAboutExportTest.php):
* Nutzer, Kunde, Bestellung, Instanz. Anders als dort schon im Zustand NACH
* der Kündigung, so wie ihn ConfirmCancelPackage::cancelPackage() hinterlässt
* status und service_ends_at sind bereits gesetzt.
*
* @return array{0: User, 1: Instance}
*/
function gekuendigteInstanz(\Illuminate\Support\Carbon $endetIn, string $status = 'cancellation_scheduled'): array
{
$user = User::factory()->create(['email' => 'gekuendigt@example.test', 'email_verified_at' => now()]);
$customer = Customer::factory()->create([
'email' => 'gekuendigt@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' => $status,
'subdomain' => 'gekuendigt',
'cancel_requested_at' => now()->subDays(2),
'service_ends_at' => $endetIn,
]);
return [$user, $instance->refresh()];
}
/**
* Ein Kunde mit einer laufenden Instanz, die niemand gekündigt hat
* dieselbe Form wie gekuendigteInstanz(), nur ohne Kündigung.
*
* @return array{0: User, 1: Instance}
*/
function laufendeInstanz(): array
{
$user = User::factory()->create(['email' => 'laeuft@example.test', 'email_verified_at' => now()]);
$customer = Customer::factory()->create([
'email' => 'laeuft@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' => 'laeuft',
]);
return [$user, $instance->refresh()];
}
it('zeigt nach einer Kuendigung, wie lange der Zugang noch steht', function () {
[$user, $instance] = gekuendigteInstanz(endetIn: now()->addDays(12));
Livewire::actingAs($user)->test(Dashboard::class)
->assertSee(__('dashboard.ending.title'))
->assertSee($instance->service_ends_at->local()->isoFormat('LL'));
});
it('zeigt den Streifen nicht, solange nicht gekuendigt wurde', function () {
[$user] = laufendeInstanz();
Livewire::actingAs($user)->test(Dashboard::class)
->assertDontSee(__('dashboard.ending.title'));
});
it('zeigt ihn auch nach dem Laufzeitende nicht mehr', function () {
// Danach ist der Zugang zu, und ein Streifen, der eine Restzeit nennt,
// wäre schlicht falsch. Was dann gilt, sagt die Seite an anderer Stelle.
[$user] = gekuendigteInstanz(endetIn: now()->subDay(), status: 'ended');
Livewire::actingAs($user)->test(Dashboard::class)
->assertDontSee(__('dashboard.ending.title'));
});
it('nennt keine Restzeit, die schon abgelaufen ist', function () {
// Der Fall zwischen den Stühlen: das Laufzeitende ist vorbei, aber der
// stündliche Lauf hat die Instanz noch nicht auf `ended` gestellt. Eine
// Zeile "noch -3 Stunden" ist die Sorte Fehler, die niemand meldet und
// jeder sieht.
//
// Korrektur gegenüber dem Zettel: `not->toContain('-')` über das ganze
// Dokument trägt nicht — ein Datum und eine CSS-Klasse enthalten ebenso
// Bindestriche, und die Prüfung wäre aus dem falschen Grund rot oder
// grün. Sie zielt stattdessen auf die Restzeit selbst: der Streifen darf
// gar nicht erst erscheinen, wenn das Laufzeitende vorbei ist — unabhängig
// davon, ob der Status das schon nachvollzogen hat. Genau das ist die
// Prüfung, die eine Implementierung ertappt, die sich allein auf den
// Status verlässt und das Datum ignoriert.
[$user] = gekuendigteInstanz(endetIn: now()->subHours(3));
Livewire::actingAs($user)->test(Dashboard::class)
->assertDontSee(__('dashboard.ending.title'));
});
it('nennt die Restzeit in ganzen Tagen, solange mehr als ein Tag bleibt', function () {
// Nicht im Zettel geprüft, aber ausdrücklich gefordert: "noch 12 Tage"
// liest sich, "noch 11 Tage 7 Stunden" nicht — abgerundet auf ganze Tage.
[$user] = gekuendigteInstanz(endetIn: now()->addDays(3)->addHours(5));
Livewire::actingAs($user)->test(Dashboard::class)
->assertSee(trans_choice('dashboard.ending.remaining_days', 3));
});
it('zaehlt am letzten Tag in Stunden, nicht mehr in Tagen', function () {
[$user] = gekuendigteInstanz(endetIn: now()->addHours(5));
Livewire::actingAs($user)->test(Dashboard::class)
->assertSee(trans_choice('dashboard.ending.remaining_hours', 5));
});
it('traegt die Antwort zum Export und laesst sie aendern', function () {
[$user, $instance] = gekuendigteInstanz(endetIn: now()->addDays(12));
$instance->update(['export_wish' => false]);
Livewire::actingAs($user)->test(Dashboard::class)
->assertSee(__('dashboard.ending.export_no'))
->call('setExportWish', true);
expect($instance->fresh()->export_wish)->toBeTrue();
});
it('laesst die Antwort nach dem Laufzeitende nicht mehr aendern', function () {
// Aufgabe 4: danach ist die Instanz `ended`, die Adresse eingezogen, und
// ob ein Export vorbereitet wurde, ist entschieden. Ein Aufruf, der dort
// noch etwas verspricht, wäre die nächste Attrappe — geprüft direkt gegen
// die Methode, nicht nur gegen den (dann ohnehin nicht mehr sichtbaren)
// Schalter im Streifen: setExportWish() ist über /livewire/update auch am
// Formular vorbei erreichbar.
[$user, $instance] = gekuendigteInstanz(endetIn: now()->subDay(), status: 'ended');
$instance->update(['export_wish' => false]);
Livewire::actingAs($user)->test(Dashboard::class)
->call('setExportWish', true);
expect($instance->fresh()->export_wish)->toBeFalse();
});