diff --git a/app/Console/Commands/RemindEndingServices.php b/app/Console/Commands/RemindEndingServices.php new file mode 100644 index 0000000..46ccae3 --- /dev/null +++ b/app/Console/Commands/RemindEndingServices.php @@ -0,0 +1,109 @@ +option('dry-run'); + $reminded = 0; + + $due = Instance::query() + ->with('customer') + ->where('status', 'cancellation_scheduled') + ->whereNotNull('service_ends_at') + // In der Zukunft und höchstens sieben Tage entfernt — siehe + // Kopfkommentar. Eine bereits abgelaufene Instanz gehört + // EndDueServices, nicht mehr dieser Erinnerung. + ->where('service_ends_at', '>', now()) + ->where('service_ends_at', '<=', now()->addDays(7)) + ->whereNull('export_reminded_at') + ->cursor(); + + foreach ($due as $instance) { + $customer = $instance->customer; + + if ($customer === null || blank($customer->email)) { + // Laut, aber ohne export_reminded_at zu setzen: eine Mail, die + // nie eine Adresse hatte, ist keine, die verschickt wurde. + Log::warning('Instanz nähert sich dem Laufzeitende, aber kein erreichbarer Kunde dahinter.', [ + 'instance' => $instance->uuid, + ]); + + continue; + } + + $this->line(($dryRun ? '[dry-run] ' : '') + ."{$instance->subdomain}: Laufzeit endet am " + .$instance->service_ends_at->local()->isoFormat('LL')); + + if ($dryRun) { + $reminded++; + + continue; + } + + try { + $mail = new ServiceEndingSoonMail($instance); + Mail::mailer($mail->mailer)->to($customer->email)->queue($mail); + } catch (Throwable $e) { + Log::warning('Die Erinnerung vor dem Laufzeitende ließ sich nicht einreihen.', [ + 'instance' => $instance->uuid, + 'exception' => $e->getMessage(), + ]); + + continue; + } + + // Hält fest, dass erinnert wurde — ohne diese Spalte schickte der + // tägliche Lauf dieselbe Mail an jedem der restlichen Tage erneut. + $instance->update(['export_reminded_at' => now()]); + $reminded++; + } + + $this->info($dryRun + ? "Probelauf: {$reminded} Erinnerung(en) würden verschickt. Nichts wurde geändert." + : "{$reminded} Erinnerung(en) verschickt."); + + return self::SUCCESS; + } +} diff --git a/app/Mail/ServiceEndingSoonMail.php b/app/Mail/ServiceEndingSoonMail.php new file mode 100644 index 0000000..4a37210 --- /dev/null +++ b/app/Mail/ServiceEndingSoonMail.php @@ -0,0 +1,66 @@ +mailer(MailCatalogue::mailer('service-ending-soon')); + } + + public function envelope(): Envelope + { + return $this->mailboxEnvelope( + MailPurpose::PROVISIONING, + __('service_ending.subject', ['date' => $this->instance->service_ends_at->local()->isoFormat('LL')]), + 'service-ending-soon', + ); + } + + public function content(): Content + { + return new Content(view: 'mail.service-ending-soon', with: [ + 'name' => (string) ($this->instance->customer?->name ?? ''), + // ->local() (R19): das Datum, das dem Kunden genannt wird, muss + // seine eigene Wanduhr zeigen, nicht die Speicherzone UTC. + 'date' => $this->instance->service_ends_at->local()->isoFormat('LL'), + // https:// davor, wie überall sonst, wo Instance::address() zu + // einer klickbaren Adresse wird (z. B. App\Livewire\ + // Dashboard::domain()) — die eine Stelle, die eigene Domain vor + // Subdomain entscheidet, statt das hier zu wiederholen. + 'url' => 'https://'.$this->instance->address(ProvisioningSettings::dnsZone()), + 'exportWish' => $this->instance->export_wish, + ]); + } +} diff --git a/app/Services/Mail/MailCatalogue.php b/app/Services/Mail/MailCatalogue.php index a2357e3..d586134 100644 --- a/app/Services/Mail/MailCatalogue.php +++ b/app/Services/Mail/MailCatalogue.php @@ -34,6 +34,11 @@ final class MailCatalogue 'cloud-ready' => ['label' => 'Cloud ist bereit (Zugangsdaten)', 'purpose' => MailPurpose::PROVISIONING], 'maintenance-announcement' => ['label' => 'Wartungsfenster angekündigt', 'purpose' => MailPurpose::MAINTENANCE], 'maintenance-cancelled' => ['label' => 'Wartungsfenster abgesagt', 'purpose' => MailPurpose::MAINTENANCE], + // Die Erinnerung vor dem Laufzeitende (App\Mail\ServiceEndingSoonMail). + // Kein Rundschreiben und keine Werbung, sondern eine Frist, auf die + // der Empfänger wartet — PROVISIONING wie cloud-ready, weil es um + // die Maschine selbst geht, nicht um Geld. + 'service-ending-soon' => ['label' => 'Cloud endet bald (Erinnerung)', 'purpose' => MailPurpose::PROVISIONING], 'invoice' => ['label' => 'Rechnung', 'purpose' => MailPurpose::BILLING], 'new-device' => ['label' => 'Anmeldung von einem neuen Gerät', 'purpose' => MailPurpose::SYSTEM], 'dormant-warning' => ['label' => 'Konto ohne Paket wird gelöscht', 'purpose' => MailPurpose::SYSTEM], diff --git a/app/Services/Mail/MailLane.php b/app/Services/Mail/MailLane.php index e46f99e..4879428 100644 --- a/app/Services/Mail/MailLane.php +++ b/app/Services/Mail/MailLane.php @@ -16,6 +16,7 @@ use App\Mail\OperatorMessageMail; use App\Mail\OrderConfirmationMail; use App\Mail\ResetPasswordMail; use App\Mail\SecurityBlockMail; +use App\Mail\ServiceEndingSoonMail; use App\Mail\VerifyEmailMail; use App\Support\Settings; use RuntimeException; @@ -70,6 +71,9 @@ final class MailLane MaintenanceCancelledMail::class => self::URGENT, CloudSuspendedMail::class => self::URGENT, CloudResumedMail::class => self::URGENT, + // Kein Rundschreiben und keine Werbung, sondern eine Frist, auf die + // der Empfänger wartet — wie die Wartungsankündigung daneben. + ServiceEndingSoonMail::class => self::URGENT, InvoiceMail::class => self::CALM, DunningNoticeMail::class => self::CALM, DormantAccountWarningMail::class => self::CALM, diff --git a/app/Services/Mail/MailPreviews.php b/app/Services/Mail/MailPreviews.php index 6d6fa67..6ade91e 100644 --- a/app/Services/Mail/MailPreviews.php +++ b/app/Services/Mail/MailPreviews.php @@ -17,6 +17,7 @@ use App\Mail\OperatorMessageMail; use App\Mail\OrderConfirmationMail; use App\Mail\ResetPasswordMail; use App\Mail\SecurityBlockMail; +use App\Mail\ServiceEndingSoonMail; use App\Mail\VerifyEmailMail; use App\Models\Customer; use App\Models\Instance; @@ -107,6 +108,7 @@ class MailPreviews 'cloud-ready' => $this->cloudReady($customer), 'maintenance-announcement' => new MaintenanceAnnouncementMail($this->window(), $customer), 'maintenance-cancelled' => new MaintenanceCancelledMail($this->window(), $customer), + 'service-ending-soon' => new ServiceEndingSoonMail($this->endingInstance($customer)), 'invoice' => new InvoiceMail($this->invoice($customer), $customer->name), 'new-device' => new NewDeviceSignInMail($customer->name, $this->device($user), 'web'), 'dormant-warning' => new DormantAccountWarningMail($user, PruneDormantAccounts::WARN_DAYS_BEFORE), @@ -243,6 +245,21 @@ class MailPreviews ])->forceFill(['id' => 0])->setRelation('instance', $instance); } + /** + * Eine gekündigte Instanz sechs Tage vor dem Laufzeitende, mit + * gewünschtem Export — die Kombination, die den zusätzlichen Absatz + * zeigt. make(), nie create(), wie alles hier. + */ + private function endingInstance(Customer $customer): Instance + { + return Instance::make([ + 'subdomain' => 'nc-beispiel', + 'status' => 'cancellation_scheduled', + 'service_ends_at' => Carbon::now()->addDays(6), + 'export_wish' => true, + ])->forceFill(['id' => 0])->setRelation('customer', $customer); + } + /** * The credentials mail is a notification rather than a Mailable. * diff --git a/lang/de/mail_pace.php b/lang/de/mail_pace.php index 4614f34..b288bcb 100644 --- a/lang/de/mail_pace.php +++ b/lang/de/mail_pace.php @@ -58,6 +58,7 @@ return [ 'MaintenanceCancelledMail' => 'Wartungsfenster abgesagt', 'CloudSuspendedMail' => 'Cloud abgeschaltet', 'CloudResumedMail' => 'Cloud läuft wieder', + 'ServiceEndingSoonMail' => 'Cloud endet bald (Erinnerung)', 'InvoiceMail' => 'Rechnung', 'DunningNoticeMail' => 'Zahlungserinnerungen und Mahnungen', 'DormantAccountWarningMail' => 'Konto ohne Paket wird gelöscht', diff --git a/lang/de/service_ending.php b/lang/de/service_ending.php new file mode 100644 index 0000000..b02d9be --- /dev/null +++ b/lang/de/service_ending.php @@ -0,0 +1,15 @@ + 'Ihre Cloud endet am :date', + 'heading' => 'Bald ist Schluss', + 'preheader' => 'Sichern Sie sich, was Sie brauchen — ab :date kommen Sie nicht mehr hinein.', + 'greeting' => 'Guten Tag :name,', + 'intro' => 'Ihr Vertrag endet am :date. Ab diesem Zeitpunkt ziehen wir die Adresse Ihrer Cloud ein — Sie kommen dann nicht mehr an Ihre eigenen Dateien.', + 'how_to_download' => 'Melden Sie sich bis dahin selbst in Ihrer Cloud an und laden Sie herunter, was Sie behalten möchten.', + 'action' => 'Zur eigenen Cloud', + 'export_prepared' => 'Da Sie beim Kündigen einen Export gewünscht haben, bereiten wir zusätzlich einen für Sie vor — unabhängig davon lohnt es sich, vorher selbst zu sichern.', +]; diff --git a/lang/en/mail_pace.php b/lang/en/mail_pace.php index 3f3eec8..15cd5eb 100644 --- a/lang/en/mail_pace.php +++ b/lang/en/mail_pace.php @@ -52,6 +52,7 @@ return [ 'MaintenanceCancelledMail' => 'Maintenance window cancelled', 'CloudSuspendedMail' => 'Cloud suspended', 'CloudResumedMail' => 'Cloud running again', + 'ServiceEndingSoonMail' => 'Cloud ending soon (reminder)', 'InvoiceMail' => 'Invoice', 'DunningNoticeMail' => 'Payment reminders and dunning notices', 'DormantAccountWarningMail' => 'Account without a plan will be deleted', diff --git a/lang/en/service_ending.php b/lang/en/service_ending.php new file mode 100644 index 0000000..5a4cffb --- /dev/null +++ b/lang/en/service_ending.php @@ -0,0 +1,12 @@ + 'Your cloud ends on :date', + 'heading' => 'Time is running out', + 'preheader' => 'Save what you need — from :date you can no longer get in.', + 'greeting' => 'Hello :name,', + 'intro' => 'Your contract ends on :date. From that point we withdraw the address of your cloud — you will no longer be able to reach your own files.', + 'how_to_download' => 'Sign in to your cloud yourself before then and download whatever you want to keep.', + 'action' => 'Go to my cloud', + 'export_prepared' => 'Since you asked for an export when you cancelled, we are additionally preparing one for you — either way, it is worth saving your own copy first.', +]; diff --git a/resources/views/mail/service-ending-soon.blade.php b/resources/views/mail/service-ending-soon.blade.php new file mode 100644 index 0000000..c019e94 --- /dev/null +++ b/resources/views/mail/service-ending-soon.blade.php @@ -0,0 +1,37 @@ + + + +

{{ __('service_ending.intro', ['date' => $date]) }}

+ + +{{-- Das eine Ziel dieser Mail: bis wann, und wie komme ich vorher an meine + eigenen Dateien. Ein Knopf statt einer Adresse im Fliesstext, weil das + hier keine Nebeninformation ist, sondern der Grund, warum die Mail + verschickt wird. --}} + +

{{ __('service_ending.how_to_download') }}

+ + +
+ {{ __('service_ending.action') }} +
+ + +{{-- Nur, wer beim Kündigen "ja" gesagt hat. Ein "nein" bedeutet ausdrücklich + "bereitet nichts vor" (ConfirmCancelPackage) — ihm hier trotzdem einen + Export anzukündigen wäre ein Versprechen, das niemand eingelöst hätte. --}} +@if ($exportWish === true) + + + +
+

{{ __('service_ending.export_prepared') }}

+
+ +@endif + +
diff --git a/routes/console.php b/routes/console.php index 277ea06..6c5ce0f 100644 --- a/routes/console.php +++ b/routes/console.php @@ -251,6 +251,21 @@ Schedule::command('clupilot:end-due-services') ->hourly() ->withoutOverlapping(); +// Die Erinnerung, bevor der Lauf oben die Adresse einzieht. +// +// Ab dem Laufzeitende kommt der Kunde selbst nicht mehr an seine Nextcloud — +// wer eigene Dateien will, muss es VORHER tun, und bislang erfuhr er das +// nirgends. RemindEndingServices sagt es ihm einmalig, bis zu sieben Tage +// vorher. +// +// Täglich statt stündlich: die Erinnerung hat einen Tag Spielraum, und eine +// Mail, die um drei Uhr nachts kommt, liest niemand anders als eine um neun — +// anders als beim Mahnlauf oben verspricht hier niemand eine Geschäftsstunde, +// nur einen Termin mit einer Woche Vorlauf. +Schedule::command('clupilot:remind-ending-services') + ->dailyAt('03:50') + ->withoutOverlapping(); + // Zertifikate: welche Namen bedient diese Installation, und wie lange gelten // sie noch. // diff --git a/tests/Feature/Cancellation/RemindEndingServicesTest.php b/tests/Feature/Cancellation/RemindEndingServicesTest.php new file mode 100644 index 0000000..9890835 --- /dev/null +++ b/tests/Feature/Cancellation/RemindEndingServicesTest.php @@ -0,0 +1,86 @@ +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 täglicher Zeitplan würde die Erinnerung sonst an jedem der + // restlichen Tage 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 gekündigt 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 Läufen durchgerutscht ist, + // oder eine, die mit kurzer Frist gekündigt wurde. Lieber spät 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('trägt 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 Schlüssel, 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')); +});