From 452d1ac17f1e9e5b99fcbb261c2eeba7362ab655 Mon Sep 17 00:00:00 2001 From: Kevin Date: Wed, 23 Sep 2026 15:44:45 +0000 Subject: [PATCH] Abos: taegliche Warnung fuer ueberfaellige Abos, ohne Nachbuchung user:make_abo_order nimmt nur Abos mit next_date = heute; faellt ein Lauf aus, bleibt ein Abo stillschweigend liegen (Abo 39: Termin 25.03.2026). abo:check-overdue meldet taeglich um 05:30 alle aktiven Abos mit Status okay und verstrichenem Termin per Mail an mivita (Link ins Admin-Abo) und ins abo_order-Log. Es wird bewusst nichts nachgebucht; --no-mail nur fuer die Ausgabe. Co-Authored-By: Claude Opus 5.5 (1M context) --- app/Console/Commands/AboCheckOverdue.php | 104 ++++++++++++++++++ app/Console/Kernel.php | 3 + app/Mail/AboOverdueAlertMail.php | 45 ++++++++ .../views/emails/abo_overdue_alert.blade.php | 60 ++++++++++ tests/Feature/AboCheckOverdueTest.php | 76 +++++++++++++ 5 files changed, 288 insertions(+) create mode 100644 app/Console/Commands/AboCheckOverdue.php create mode 100644 app/Mail/AboOverdueAlertMail.php create mode 100644 resources/views/emails/abo_overdue_alert.blade.php create mode 100644 tests/Feature/AboCheckOverdueTest.php diff --git a/app/Console/Commands/AboCheckOverdue.php b/app/Console/Commands/AboCheckOverdue.php new file mode 100644 index 00000000..b8699ea8 --- /dev/null +++ b/app/Console/Commands/AboCheckOverdue.php @@ -0,0 +1,104 @@ +overdueAbos(); + + if ($overdue->isEmpty()) { + $this->info('Keine überfälligen Abos.'); + Log::channel('abo_order')->info('abo:check-overdue: keine ueberfaelligen Abos'); + + return self::SUCCESS; + } + + $this->warn('Überfällige Abos: '.$overdue->count()); + $this->table( + ['Abo', 'Art', 'Kunde/Berater', 'Termin', 'Tage überfällig', 'Letzte Ausführung'], + $overdue->map(fn (UserAbo $abo) => [ + $abo->id, + $abo->is_for, + $this->holderName($abo), + Carbon::parse($abo->next_date)->format('d.m.Y'), + $this->daysOverdue($abo), + $abo->last_date ? Carbon::parse($abo->last_date)->format('d.m.Y') : '–', + ])->all(), + ); + + Log::channel('abo_order')->warning('abo:check-overdue: '.$overdue->count().' ueberfaellige Abos', [ + 'abo_ids' => $overdue->pluck('id')->all(), + ]); + + if (! $this->option('no-mail')) { + try { + Mail::to(config('app.default_mail'))->send(new AboOverdueAlertMail($overdue->all())); + } catch (\Throwable $e) { + Log::channel('abo_order')->error('abo:check-overdue: Mail fehlgeschlagen: '.$e->getMessage()); + } + } + + return self::SUCCESS; + } + + /** + * Aktiv, Status „okay“, Termin vor heute — genau die Abos, die der Ausführungslauf + * nie mehr anfassen würde. + * + * @return Collection + */ + private function overdueAbos(): Collection + { + return UserAbo::query() + ->with(['shopping_user', 'user.account', 'member.account']) + ->where('active', true) + ->where('status', 2) + ->whereDate('next_date', '<', now()->toDateString()) + ->orderBy('next_date') + ->get(); + } + + private function daysOverdue(UserAbo $abo): int + { + return (int) Carbon::parse($abo->next_date)->startOfDay()->diffInDays(now()->startOfDay()); + } + + private function holderName(UserAbo $abo): string + { + if ($abo->is_for === 'me') { + return trim($abo->user?->account?->first_name.' '.$abo->user?->account?->last_name) ?: (string) $abo->email; + } + + return trim($abo->shopping_user?->billing_firstname.' '.$abo->shopping_user?->billing_lastname) ?: (string) $abo->email; + } +} diff --git a/app/Console/Kernel.php b/app/Console/Kernel.php index 9100f989..fe88923d 100644 --- a/app/Console/Kernel.php +++ b/app/Console/Kernel.php @@ -52,6 +52,9 @@ class Kernel extends ConsoleKernel $schedule->command('user:cleanup')->dailyAt('03:30'); $schedule->command('user:make_abo_order')->dailyAt('04:00'); + // Laufende Abos mit verstrichenem Termin melden — ohne Nachbuchung (Befund Abo 39). + $schedule->command('abo:check-overdue')->dailyAt('05:30'); + // Stripe: liegengebliebene Webhook-Events nachverarbeiten (Cron-Race / // verzögerter Stripe-Retry; Stripe CLI auf dem Test-Server retryt gar nicht). $schedule->command('stripe:process-pending-events') diff --git a/app/Mail/AboOverdueAlertMail.php b/app/Mail/AboOverdueAlertMail.php new file mode 100644 index 00000000..e1ba75b9 --- /dev/null +++ b/app/Mail/AboOverdueAlertMail.php @@ -0,0 +1,45 @@ + $overdueAbos + */ + public function __construct(public array $overdueAbos) {} + + public function build(): self + { + $base = rtrim(Util::getMyMivitaUrl(), '/'); + + $rows = array_map(fn (UserAbo $abo) => [ + 'id' => $abo->id, + 'is_for' => $abo->is_for === 'me' ? 'Berater-Abo' : 'Kundenabo', + 'name' => $abo->is_for === 'me' + ? trim($abo->user?->account?->first_name.' '.$abo->user?->account?->last_name) + : trim($abo->shopping_user?->billing_firstname.' '.$abo->shopping_user?->billing_lastname), + 'next_date' => Carbon::parse($abo->next_date)->format('d.m.Y'), + 'days' => (int) Carbon::parse($abo->next_date)->startOfDay()->diffInDays(now()->startOfDay()), + 'url' => $base.'/admin/abos/detail/'.$abo->id, + ], $this->overdueAbos); + + return $this->subject('Überfällige Abos: '.count($rows)) + ->view('emails.abo_overdue_alert') + ->with(['rows' => $rows]); + } +} diff --git a/resources/views/emails/abo_overdue_alert.blade.php b/resources/views/emails/abo_overdue_alert.blade.php new file mode 100644 index 00000000..3574e5ea --- /dev/null +++ b/resources/views/emails/abo_overdue_alert.blade.php @@ -0,0 +1,60 @@ + + + + + + Überfällige Abos – mivita.care + + + + + + + +
+
+ + + + + + + + + + + + + +
Überfällige Abos: {{ count($rows) }}
+ Diese laufenden Abos haben einen Ausführungstermin in der Vergangenheit und werden vom + nächtlichen Abo-Lauf nicht mehr ausgeführt. Es wurde nichts nachgebucht. Bitte je Abo + entscheiden: nachliefern, Termin korrigieren oder Abo beenden. +
+ + + + + + + + + @foreach ($rows as $row) + + + + + + + + @endforeach +
AboArtNameTerminTage überfällig
#{{ $row['id'] }}{{ $row['is_for'] }}{{ $row['name'] !== '' ? $row['name'] : '–' }}{{ $row['next_date'] }}{{ $row['days'] }}
+
Automatische Meldung von abo:check-overdue, täglich 05:30.
+
+ + diff --git a/tests/Feature/AboCheckOverdueTest.php b/tests/Feature/AboCheckOverdueTest.php new file mode 100644 index 00000000..ae9ce7c9 --- /dev/null +++ b/tests/Feature/AboCheckOverdueTest.php @@ -0,0 +1,76 @@ + Carbon::setTestNow()); + +function overdueAbo(string $nextDate, int $status = 2, bool $active = true): UserAbo +{ + return UserAbo::create([ + 'user_id' => null, + 'member_id' => 1, + 'shopping_user_id' => 0, + 'is_for' => 'ot', + 'email' => 'abo-'.uniqid('', true).'@example.com', + 'payone_userid' => 1, + 'clearingtype' => 'cc', + 'active' => $active, + 'status' => $status, + 'abo_interval' => (int) Carbon::parse($nextDate)->day, + 'start_date' => '2026-01-01', + 'next_date' => $nextDate, + ]); +} + +it('meldet laufende Abos mit verstrichenem Termin und bucht nichts nach', function () { + $stuck = overdueAbo('2026-03-25'); + overdueAbo('2026-09-23'); // heute faellig: laeuft regulaer + overdueAbo('2026-10-05'); // Zukunft + overdueAbo('2026-08-10', status: 3); // Zahlung fehlgeschlagen: eigener Zustand + overdueAbo('2026-08-10', active: false); // inaktiv + + $this->artisan('abo:check-overdue') + ->expectsOutputToContain('Überfällige Abos: 1') + ->assertSuccessful(); + + Mail::assertSent(AboOverdueAlertMail::class, function (AboOverdueAlertMail $mail) use ($stuck) { + $html = $mail->render(); + + return $mail->hasTo(config('app.default_mail')) + && count($mail->overdueAbos) === 1 + && str_contains($html, '#'.$stuck->id) + && str_contains($html, '25.03.2026') + && str_contains($html, '/admin/abos/detail/'.$stuck->id); + }); + + expect(Carbon::parse($stuck->fresh()->getRawOriginal('next_date'))->toDateString())->toBe('2026-03-25') + ->and($stuck->user_abo_orders()->count())->toBe(0); +}); + +it('schickt ohne ueberfaellige Abos keine Mail', function () { + overdueAbo('2026-10-05'); + + $this->artisan('abo:check-overdue')->expectsOutputToContain('Keine überfälligen Abos.')->assertSuccessful(); + + Mail::assertNothingSent(); +}); + +it('schickt mit --no-mail nur die Ausgabe', function () { + overdueAbo('2026-09-01'); + + $this->artisan('abo:check-overdue', ['--no-mail' => true])->assertSuccessful(); + + Mail::assertNothingSent(); +});