# Kündigung, Teil A: die Frage und die Warnung — Umsetzungsplan

> **Für agentische Arbeiter:** ERFORDERLICHE UNTER-SKILL: `superpowers:subagent-driven-development` (empfohlen) oder `superpowers:executing-plans`. Schritte benutzen Checkbox-Syntax (`- [ ]`).

**Ziel:** Ein kündigender Kunde wird gefragt, ob er einen Datenexport möchte, und er wird gewarnt, bevor ihn seine eigene Cloud aussperrt.

**Architektur:** Zwei Zustandsfelder an der Instanz, eine Frage im Kündigungsdialog, eine Seite im Portal zum Ändern, und ein Zeitplan-Befehl, der sieben Tage vor Laufzeitende erinnert. Kein Gast wird angefasst, kein Speicher gebraucht.

**Entwurf:** `docs/superpowers/specs/2026-08-04-kuendigung-export-abbau-design.md`

---

## Warum dieser Plan bei A aufhört

Der Entwurf beschreibt den ganzen Weg: fragen, erinnern, exportieren, bestätigen, abbauen, archivieren. Gebaut wird hier nur die erste Hälfte.

**Der Grund ist nicht Bequemlichkeit, sondern eine Lehre von heute.** An diesem Vorhaben sind neun Befunde aufgelaufen, und jeder einzelne stammte aus einem Plan, der an einer Stelle selbstsicher war, an der niemand nachgesehen hatte. Teil B — der Export selbst und der Abbau — hängt an einem **Speicherserver, den es noch nicht gibt**: keine Adresse, keine Zugangsdaten, kein Wissen darüber, welches Werkzeug auf den Proxmox-Hosts wirklich vorhanden ist.

Dafür jetzt Code zu schreiben hiesse, Pfade, Befehle und Fehlerfälle zu erfinden und sie dann als Plan auszugeben. Teil B bekommt seinen eigenen Plan, wenn die Kiste steht und man sie befragen kann.

**Teil A ist trotzdem für sich nützlich, und zwar für den Punkt, der heute am meisten weh tut:** ab dem Laufzeitende kommt ein Kunde nicht mehr an seine Nextcloud, und niemand sagt es ihm vorher. Wer selbst herunterladen will, erfährt heute erst davon, wenn es zu spät ist. Das behebt dieser Teil allein.

---

## Global Constraints

- **Eine Kündigung endet nichts früher.** `EndInstanceService::hasEnded()` bleibt unangetastet. Wer am Zweiten kündigt, arbeitet bis Monatsende.
- **Kein Gast wird angefasst.** Dieser Teil schreibt Datenbankfelder und verschickt Mails. Nichts läuft über den Gastagenten, nichts über SSH.
- **Jede Mailart gehört in `MailCatalogue`** und in `MailLane` — sonst fällt sie stumm auf die gedrosselte Spur und ist in der Versandtakt-Übersicht unsichtbar. Das ist heute schon einmal aufgefallen.
- Kommentare erklären das WARUM, auf Deutsch, **mit Umlauten** — so schreibt dieser Bestand.
- Sprachdateien immer **beide** (`lang/de/`, `lang/en/`), mit denselben Schlüsseln.
- R19: jede Zeit, die ein Mensch liest, geht durch `->local()`. R23: Bestätigen im Modal, nie `wire:confirm`. R24: Modal nie höher als der Bildschirm.
- Tests: `docker compose exec -u 1000:1000 -T app php artisan test`. Niemals `php artisan` direkt auf dem Host.

---

## Dateiaufstellung

| Datei | Verantwortung |
|---|---|
| `database/migrations/…_der_kunde_sagt_ob_er_einen_export_will.php` | **neu** — die Felder |
| `app/Models/Instance.php` | **ändern** — Zustände, Casts |
| `app/Livewire/ConfirmCancelPackage.php` | **ändern** — die Frage |
| `resources/views/livewire/confirm-cancel-package.blade.php` | **ändern** — die Frage |
| `app/Livewire/Billing.php` (oder wo das Paket im Portal steht) | **ändern** — Antwort später ändern |
| `app/Mail/ServiceEndingSoonMail.php` | **neu** — die Erinnerung |
| `app/Console/Commands/RemindEndingServices.php` | **neu** — sieben Tage vorher |
| `routes/console.php` | **ändern** — täglich |
| `app/Services/Mail/MailCatalogue.php`, `MailLane.php`, `MailPreviews.php` | **ändern** — die neue Mailart |
| `lang/{de,en}/…` | **ändern** |

---

## Task 1: Die Instanz merkt sich, was der Kunde will

**Files:**
- Create: `database/migrations/2026_08_04_140000_der_kunde_sagt_ob_er_einen_export_will.php`
- Modify: `app/Models/Instance.php`
- Test: `tests/Feature/Cancellation/ExportWishTest.php`

**Interfaces:**
- Produces: `instances.export_wish` (`null` = nicht gefragt, `true`/`false` = beantwortet), `instances.export_reminded_at`

- [ ] **Schritt 1: Die fehlschlagende Prüfung schreiben**

```php
<?php // tests/Feature/Cancellation/ExportWishTest.php

use App\Models\Instance;

it('weiss bei einer Instanz, die nie gekuendigt wurde, gar nichts', function () {
    // Drei Zustaende, nicht zwei. `null` heisst "nicht gefragt" und ist etwas
    // anderes als "nein" — eine Bestandsinstanz, die vor diesem Bau gekuendigt
    // wurde, hat die Frage nie gesehen, und wir duerfen ihr keine Antwort
    // unterstellen.
    expect(Instance::factory()->create()->export_wish)->toBeNull();
});

it('haelt ein Ja und ein Nein auseinander', function () {
    expect(Instance::factory()->create(['export_wish' => true])->export_wish)->toBeTrue()
        ->and(Instance::factory()->create(['export_wish' => false])->export_wish)->toBeFalse();
});

it('merkt sich, wann erinnert wurde, damit es nicht zweimal geschieht', function () {
    $instance = Instance::factory()->create(['export_reminded_at' => now()]);

    expect($instance->fresh()->export_reminded_at)->not->toBeNull();
});
```

- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen**

Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Cancellation/ExportWishTest.php`
Erwartet: FEHLSCHLAG, Spalte unbekannt

- [ ] **Schritt 3: Die Wanderung schreiben**

```php
<?php

use Illuminate\Database\Migrations\Migration;
use Illuminate\Database\Schema\Blueprint;
use Illuminate\Support\Facades\Schema;

/**
 * Was der Kunde beim Kuendigen zu seinen Daten gesagt hat.
 *
 * `export_wish` fuehrt DREI Zustaende, nicht zwei: `null` heisst "noch nicht
 * gefragt". Das ist kein Feinschliff — jede Instanz, die vor diesem Bau
 * gekuendigt wurde, hat die Frage nie gesehen, und ihr ein "nein" zu
 * unterstellen hiesse, einer Handvoll Menschen still zu antworten, die nie
 * gefragt wurden.
 *
 * `export_reminded_at` haelt fest, dass die Erinnerung sieben Tage vor Ende
 * hinaus ist. Ohne diese Spalte schickte ein stuendlich laufender Zeitplan sie
 * jede Stunde erneut — und eine Erinnerung, die im Sechserpack kommt, liest
 * sich als Mahnung.
 */
return new class extends Migration
{
    public function up(): void
    {
        Schema::table('instances', function (Blueprint $table) {
            $table->boolean('export_wish')->nullable()->after('service_ends_at');
            $table->timestamp('export_reminded_at')->nullable()->after('export_wish');
        });
    }

    public function down(): void
    {
        Schema::table('instances', function (Blueprint $table) {
            $table->dropColumn(['export_wish', 'export_reminded_at']);
        });
    }
};
```

- [ ] **Schritt 4: Das Modell erweitern**

In `app/Models/Instance.php`: `export_wish` und `export_reminded_at` in `$fillable`, dazu die Casts `'export_wish' => 'boolean'` und `'export_reminded_at' => 'datetime'`.

**Achtung:** `boolean` als Cast macht aus `null` **nicht** `false`. Prüf das — die Unterscheidung ist der Kern dieser Aufgabe.

- [ ] **Schritt 5: Laufen lassen, grün**

Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test tests/Feature/Cancellation/ExportWishTest.php`
Erwartet: 3 grün

- [ ] **Schritt 6: Festschreiben**

```bash
git commit -F- -- database/migrations/2026_08_04_140000_der_kunde_sagt_ob_er_einen_export_will.php app/Models/Instance.php tests/Feature/Cancellation/ExportWishTest.php <<'MSG'
Die Instanz merkt sich, was der Kunde zu seinen Daten gesagt hat

Drei Zustaende, nicht zwei: `null` heisst "noch nicht gefragt". Jede Instanz,
die vor diesem Bau gekuendigt wurde, hat die Frage nie gesehen — ihr ein "nein"
zu unterstellen hiesse, still fuer Menschen zu antworten, die niemand gefragt
hat.
MSG
```

---

## Task 2: Die Frage im Kündigungsdialog

**Files:**
- Modify: `app/Livewire/ConfirmCancelPackage.php`
- Modify: `resources/views/livewire/confirm-cancel-package.blade.php`
- Modify: `lang/{de,en}/…` (die Sprachdatei, die der Dialog benutzt)
- Test: `tests/Feature/Cancellation/CancelAsksAboutExportTest.php`

**Interfaces:**
- Consumes: `instances.export_wish` aus Task 1

Der Dialog setzt heute `status`, `cancel_requested_at` und `service_ends_at` und teilt es Stripe mit. Diese Reihenfolge bleibt **unverändert** — sie ist eigens so gebaut, dass Stripe zuerst erfährt und alles andere aus dessen Antwort folgt.

- [ ] **Schritt 1: Die fehlschlagende Prüfung schreiben**

```php
<?php // tests/Feature/Cancellation/CancelAsksAboutExportTest.php

use App\Livewire\ConfirmCancelPackage;
use App\Models\Instance;
use Livewire\Livewire;

it('schreibt die Antwort des Kunden an die Instanz', function () {
    [$user, $instance] = kuendbareInstanz();

    Livewire::actingAs($user)->test(ConfirmCancelPackage::class)
        ->set('confirmName', $instance->subdomain)
        ->set('exportWish', true)
        ->call('cancelPackage');

    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('exportWish', false)
        ->call('cancelPackage');

    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)
        ->call('cancelPackage')
        ->assertHasErrors('exportWish');

    expect($instance->fresh()->status)->toBe('active');
});
```

Den Helfer `kuendbareInstanz()` baust du nach dem Muster der bestehenden Kündigungs-Prüfungen im Repo — sieh nach, wie die vorhandenen Tests zu `ConfirmCancelPackage` ihre Ausgangslage herstellen, und nimm dieselbe.

- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen**

- [ ] **Schritt 3: Die Frage einbauen**

`public ?bool $exportWish = null;` mit `#[Validate('required|boolean')]`, und im Dialog zwei Auswahlfelder — nicht eine Checkbox. **Eine Checkbox hat keinen dritten Zustand**, und „nicht angekreuzt" liesse sich nicht von „nein" unterscheiden; genau diese Unterscheidung trägt aber die ganze Aufgabe.

Der Text sagt, was die Antwort bedeutet: bei Ja bekommt der Kunde am Laufzeitende einen Link, bei Nein wird nichts vorbereitet. Und **beide** Wege sagen, bis wann er selbst herankommt.

- [ ] **Schritt 4: Laufen lassen, grün**

- [ ] **Schritt 5: Festschreiben**

```bash
git commit -F- -- app/Livewire/ConfirmCancelPackage.php resources/views/livewire/confirm-cancel-package.blade.php tests/Feature/Cancellation/CancelAsksAboutExportTest.php lang/de/… lang/en/… <<'MSG'
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.
MSG
```

---

## Task 3: Die Erinnerung, bevor die Tür zufällt

**Files:**
- Create: `app/Mail/ServiceEndingSoonMail.php`
- Create: `resources/views/mail/service-ending-soon.blade.php`
- Create: `app/Console/Commands/RemindEndingServices.php`
- Modify: `routes/console.php`
- Modify: `app/Services/Mail/MailCatalogue.php`, `MailLane.php`, `MailPreviews.php`
- Create: `lang/{de,en}/service_ending.php`
- Test: `tests/Feature/Cancellation/RemindEndingServicesTest.php`

**Das ist der Teil, der für sich allein den heutigen Schaden behebt.**

- [ ] **Schritt 1: Die fehlschlagenden Prüfungen schreiben**

```php
<?php // tests/Feature/Cancellation/RemindEndingServicesTest.php

use App\Mail\ServiceEndingSoonMail;
use App\Models\Instance;
use Illuminate\Support\Facades\Mail;

it('erinnert sieben Tage vor dem Ende', function () {
    Mail::fake();
    $instance = Instance::factory()->create([
        'status' => 'cancellation_scheduled',
        'service_ends_at' => now()->addDays(6),
    ]);

    $this->artisan('clupilot:remind-ending-services')->assertSuccessful();

    Mail::assertQueued(ServiceEndingSoonMail::class);
    expect($instance->fresh()->export_reminded_at)->not->toBeNull();
});

it('erinnert nicht zweimal', function () {
    // Ein stuendlicher Zeitplan wuerde die Erinnerung sonst jede Stunde
    // erneut schicken. Eine Erinnerung im Sechserpack liest sich als Mahnung.
    Mail::fake();
    Instance::factory()->create([
        'status' => 'cancellation_scheduled',
        'service_ends_at' => now()->addDays(6),
        'export_reminded_at' => now()->subHour(),
    ]);

    $this->artisan('clupilot:remind-ending-services')->assertSuccessful();

    Mail::assertNothingQueued();
});

it('erinnert nicht, wenn das Ende noch weit weg ist', function () {
    Mail::fake();
    Instance::factory()->create([
        'status' => 'cancellation_scheduled',
        'service_ends_at' => now()->addDays(30),
    ]);

    $this->artisan('clupilot:remind-ending-services')->assertSuccessful();

    Mail::assertNothingQueued();
});

it('erinnert eine Instanz, die gar nicht gekuendigt wurde, nicht', function () {
    Mail::fake();
    Instance::factory()->create(['status' => 'active', 'service_ends_at' => now()->addDays(6)]);

    $this->artisan('clupilot:remind-ending-services')->assertSuccessful();

    Mail::assertNothingQueued();
});

it('erinnert auch dann, wenn das Ende schon in wenigen Stunden ist', function () {
    // Der wichtigste Fall und der, den ein "genau sieben Tage"-Vergleich
    // verfehlt: eine Instanz, die zwischen zwei Laeufen durchgerutscht ist,
    // oder eine, die mit kurzer Frist gekuendigt wurde. Lieber spaet erinnern
    // als gar nicht — ab dem Laufzeitende kommt der Kunde nicht mehr hinein.
    Mail::fake();
    Instance::factory()->create([
        'status' => 'cancellation_scheduled',
        'service_ends_at' => now()->addHours(3),
    ]);

    $this->artisan('clupilot:remind-ending-services')->assertSuccessful();

    Mail::assertQueued(ServiceEndingSoonMail::class);
});

it('traegt in der Mail das Datum und den Weg zum Selbst-Herunterladen', function () {
    $instance = Instance::factory()->create([
        'status' => 'cancellation_scheduled',
        'service_ends_at' => now()->addDays(6),
        'export_wish' => false,
    ]);

    $text = (new ServiceEndingSoonMail($instance))->render();

    // Kein roher Schluessel, und die zwei Dinge, um die es geht: ab wann ist
    // zu, und wie komme ich vorher an meine Sachen.
    expect($text)->not->toContain('service_ending.')
        ->and($text)->toContain($instance->service_ends_at->local()->isoFormat('LL'));
});
```

- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen**

- [ ] **Schritt 3: Mail, Befehl und Zeitplan bauen**

Die Auswahl ist **nicht** „genau sieben Tage", sondern **„das Ende liegt in der Zukunft und höchstens sieben Tage entfernt, und es wurde noch nicht erinnert"**. Ein Gleichheitsvergleich auf einen Tag verfehlt jede Instanz, die zwischen zwei Läufen durchrutscht — und der Preis dafür ist, dass jemand ausgesperrt wird, ohne es gewusst zu haben.

Die Mailart gehört in `MailCatalogue` **und** in `MailLane`. Sieh dir an, wie `ServiceEndingSoonMail` dort einzuordnen ist: sie ist keine Werbung und kein Rundschreiben, sondern eine Frist, auf die jemand wartet.

Der Zeitplan läuft **täglich**, nicht stündlich: die Erinnerung hat einen Tag Spielraum, und eine Mail, die um 3 Uhr nachts kommt, liest niemand anders als eine um 9 Uhr.

- [ ] **Schritt 4: Laufen lassen, grün**

- [ ] **Schritt 5: Ganze Suite**

Ausführen: `docker compose exec -u 1000:1000 -T app php artisan test`

- [ ] **Schritt 6: Festschreiben**

```bash
git commit -F- <<'MSG'
Eine Warnung, bevor die eigene Cloud den Kunden aussperrt

Ab dem Laufzeitende zieht EndInstanceService die Adresse ein — der Kunde kommt
ab dem Moment nicht mehr an seine Nextcloud. Wer selbst etwas herunterladen
will, muss es VORHER tun, und das wusste bisher niemand.

Die Auswahl ist bewusst "hoechstens sieben Tage" und nicht "genau sieben Tage":
ein Gleichheitsvergleich verfehlt jede Instanz, die zwischen zwei Laeufen
durchrutscht, und der Preis dafuer waere, dass jemand ausgesperrt wird, ohne es
gewusst zu haben.
MSG
```

---

## Task 4: Der Kunde kann es sich anders überlegen

**Files:**
- Modify: die Portalseite, auf der das Paket und die Kündigung stehen
- Test: `tests/Feature/Cancellation/ChangeExportWishTest.php`

Die Antwort lässt sich bis zum Laufzeitende ändern. Es ist dieselbe Frage, und wer es sich überlegt, soll nicht anrufen müssen.

- [ ] **Schritt 1: Die fehlschlagenden Prüfungen schreiben**

```php
it('laesst den Kunden seine Antwort bis zum Laufzeitende aendern', function () { … });

it('laesst sie nach dem Laufzeitende nicht mehr aendern', function () {
    // Danach ist die Instanz `ended`, die Adresse eingezogen, und ob ein
    // Export vorbereitet wurde, ist entschieden. Ein Knopf, der dort noch
    // etwas verspricht, waere die naechste Attrappe.
    …
});
```

- [ ] **Schritt 2–5:** wie oben — fehlschlagen sehen, bauen, grün sehen, festschreiben.

---

## Task 5: Der Streifen im Dashboard, der die Frist zeigt

**Files:**
- Modify: `app/Livewire/Dashboard.php`
- Modify: `resources/views/livewire/dashboard.blade.php`
- Modify: `lang/{de,en}/dashboard.php`
- Test: `tests/Feature/Cancellation/DashboardCountdownTest.php`

**Vom Betreiber gefordert, und es ist der richtige Ort:** das Dashboard ist die
Seite, die ein Kunde ohnehin öffnet. Eine Mail sieben Tage vorher kann im
Postfach untergehen; ein Streifen über der eigenen Übersicht nicht.

Er zeigt, **wie lange der Zugang noch steht** — nicht, wann irgendetwas
gelöscht wird. Das ist die Frist, die den Kunden betrifft: ab dem Laufzeitende
kommt er nicht mehr an seine Cloud.

Und er ist der natürliche Platz für Aufgabe 4: wer hier liest, dass die Zeit
läuft, will im selben Atemzug seine Antwort zum Export ändern können.

- [ ] **Schritt 1: Die fehlschlagenden Prüfungen schreiben**

```php
<?php // tests/Feature/Cancellation/DashboardCountdownTest.php

use App\Livewire\Dashboard;
use Livewire\Livewire;

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,
    // waere 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 Stuehlen: das Laufzeitende ist vorbei, aber der
    // stuendliche Lauf hat die Instanz noch nicht auf `ended` gestellt. Eine
    // Zeile "noch -3 Stunden" ist die Sorte Fehler, die niemand meldet und
    // jeder sieht.
    [$user] = gekuendigteInstanz(endetIn: now()->subHours(3));

    $html = Livewire::actingAs($user)->test(Dashboard::class)->html();

    expect($html)->not->toContain('-');  // an der Restzeit, nicht im ganzen Dokument — siehe unten
});

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();
});
```

**Zur vierten Prüfung:** `not->toContain('-')` über das ganze Dokument trägt
nicht — ein Datum enthält Bindestriche, eine CSS-Klasse auch. Schreib sie so,
dass sie **die Restzeit** prüft und nicht die Seite: entweder gegen den Wert,
den die Komponente berechnet, oder gegen den Textbaustein mit eingesetzter
Zahl. Eine Zusicherung, die aus dem falschen Grund grün ist, ist keine.

- [ ] **Schritt 2: Laufen lassen, Fehlschlag bestätigen**

- [ ] **Schritt 3: Den Streifen bauen**

`Dashboard::render()` holt die Instanz bereits (Zeile 36 ff.) — von dort kommt
`service_ends_at`. Der Streifen erscheint, wenn `status === 'cancellation_scheduled'`
**und** das Ende in der Zukunft liegt.

Die Restzeit geht durch `->local()` (R19) und wird **abgerundet auf ganze Tage
genannt, solange mehr als ein Tag bleibt** — „noch 12 Tage" liest sich, „noch
11 Tage 7 Stunden" nicht. Am letzten Tag zählt er Stunden; das ist der Moment,
in dem die Zahl wirklich zählt.

Der Streifen benutzt dieselbe Warnfarbe wie der bestehende Zugangsdaten-Kasten
darüber (`border-warning-border bg-warning-bg text-warning`) und steht an
derselben Stelle im Fluss — nicht zwei verschiedene Sprachen für zwei
verschiedene Dringlichkeiten auf einer Seite.

Er nennt drei Dinge: das Datum, die Restzeit, und **was dann geschieht** — dass
der Zugang zur Cloud endet und ein Selbst-Herunterladen bis dahin möglich ist.
Dazu die Antwort zum Export mit einem Schalter, sie zu ändern (Aufgabe 4).

- [ ] **Schritt 4: Laufen lassen, grün**

- [ ] **Schritt 5: Ganze Suite**

- [ ] **Schritt 6: Festschreiben**

```bash
git commit -F- <<'MSG'
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.
MSG
```

---

## Selbstdurchsicht

**Abdeckung von Teil A des Entwurfs**

| Anforderung | Aufgabe |
|---|---|
| Beim Kündigen wird gefragt | 2 |
| Drei Zustände, `null` ≠ „nein" | 1 |
| Antwort später änderbar | 4, 5 |
| Erinnerung vor dem Laufzeitende | 3 |
| Streifen im Dashboard mit der Restfrist | 5 |
| Kein Gast angefasst, kein Speicher gebraucht | alle |

**Was Teil A ausdrücklich NICHT tut**

- Kein Export wird erzeugt. Wer „ja" sagt, hat das gesagt — mehr passiert in Teil A nicht.
- Keine Maschine wird abgebaut, kein Archiv angelegt.
- **Der Satz im Kündigungsdialog, der einen fertigen Export verspricht, wird nicht wahr.** Er wird auch nicht geändert: die Frage danebenzustellen ist ehrlich, solange Teil B in Arbeit ist. Wird Teil B nicht gebaut, **muss** der Satz weg — das gehört in den Merge Request.

**Namensgleichheit über die Aufgaben**

`instances.export_wish` (`?bool`) und `instances.export_reminded_at` (`?Carbon`) aus Aufgabe 1 → benutzt in 2, 3, 4.
`ServiceEndingSoonMail(Instance $instance)` aus Aufgabe 3 → in sich geschlossen.

---

## Was Teil B braucht, bevor er geplant werden kann

Damit der nächste Plan nicht wieder rät:

1. **Der Speicherserver steht** — Adresse, Zugangsweg, wie viel Platz.
2. **Es ist bekannt, welches Werkzeug auf den Proxmox-Hosts wirklich vorhanden ist** (`qemu-nbd`, `libguestfs`), und ob es die Host-Übernahme mitbringen soll.
3. **Eine Momentaufnahme ist einmal von Hand gefahren worden** — auf einer echten Instanz, mit dem Ergebnis aufgeschrieben. Ob Proxmox eine laufende Maschine so einfriert, dass das Dateisystem darin brauchbar ist, gehört gemessen und nicht angenommen.

Punkt 3 ist der, der einen Plan sonst zum zweiten Mal an derselben Stelle scheitern liesse.
