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) <noreply@anthropic.com>
This commit is contained in:
Kevin 2026-09-23 15:44:45 +00:00
parent 0b8f8abeeb
commit 452d1ac17f
5 changed files with 288 additions and 0 deletions

View file

@ -0,0 +1,104 @@
<?php
namespace App\Console\Commands;
use App\Mail\AboOverdueAlertMail;
use App\Models\UserAbo;
use Carbon\Carbon;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\Log;
use Illuminate\Support\Facades\Mail;
/**
* Warnt vor laufenden Abos, deren nächste Ausführung in der Vergangenheit liegt.
*
* `user:make_abo_order` nimmt nur Abos mit `next_date = heute`. Fällt ein Lauf aus
* oder bricht ein Abo ab, ohne dass sein Termin weitergesetzt wird, bleibt es
* stillschweigend liegen (Befund Abo 39: Termin 25.03.2026, bis September nicht
* ausgeführt). Dieser Command bucht bewusst nichts nach — er meldet nur, damit
* jemand entscheidet, ob nachgeliefert, der Termin korrigiert oder das Abo beendet wird.
*/
class AboCheckOverdue extends Command
{
/**
* @var string
*/
protected $signature = 'abo:check-overdue
{--no-mail : Nur ausgeben und loggen, keine Mail}';
/**
* @var string
*/
protected $description = 'Laufende Abos mit einem Ausführungstermin in der Vergangenheit melden (ohne Nachbuchung)';
public function handle(): int
{
$overdue = $this->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<int, UserAbo>
*/
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;
}
}

View file

@ -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')

View file

@ -0,0 +1,45 @@
<?php
namespace App\Mail;
use App\Console\Commands\AboCheckOverdue;
use App\Models\UserAbo;
use App\Services\Util;
use Carbon\Carbon;
use Illuminate\Bus\Queueable;
use Illuminate\Mail\Mailable;
use Illuminate\Queue\SerializesModels;
/**
* Interne Warnung an mivita: laufende Abos, deren Termin verstrichen ist, ohne dass
* sie ausgeführt wurden ({@see AboCheckOverdue}).
*/
class AboOverdueAlertMail extends Mailable
{
use Queueable, SerializesModels;
/**
* @param list<UserAbo> $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]);
}
}

View file

@ -0,0 +1,60 @@
<!DOCTYPE html PUBLIC "-//W3C//DTD XHTML 1.0 Transitional//EN"
"http://www.w3.org/TR/xhtml1/DTD/xhtml1-transitional.dtd">
<html xmlns="http://www.w3.org/1999/xhtml">
<head>
<meta http-equiv="Content-Type" content="text/html; charset=UTF-8"/>
<meta name="viewport" content="width=device-width, initial-scale=1"/>
<title>Überfällige Abos – mivita.care</title>
<style type="text/css">
td, h1, h2, h3 { font-family: Helvetica, Verdana, Arial, sans-serif; font-weight: 400; }
body { -webkit-font-smoothing: antialiased; color: #37302d; background: #ffffff; font-size: 15px; line-height: 24px; }
a { color: #919f7a; text-decoration: none; }
</style>
</head>
<body style="padding:0; margin:0; display:block; background:#f8f8f8;">
<table align="left" cellpadding="0" cellspacing="0" width="100%">
<tr>
<td align="left" valign="top" bgcolor="#f8f8f8" width="100%">
<br>
<table style="margin: 0 auto;" cellpadding="10" cellspacing="0" width="700" bgcolor="#ffffff">
<tr>
<td style="font-size: 18px; font-weight: bold;">Überfällige Abos: {{ count($rows) }}</td>
</tr>
<tr>
<td>
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.
</td>
</tr>
<tr>
<td>
<table cellpadding="6" cellspacing="0" width="100%" style="font-size: 13px;">
<tr style="background:#f6fdf5;">
<td><b>Abo</b></td>
<td><b>Art</b></td>
<td><b>Name</b></td>
<td><b>Termin</b></td>
<td><b>Tage überfällig</b></td>
</tr>
@foreach ($rows as $row)
<tr>
<td style="border-bottom:1px solid #eeeeee;"><a href="{{ $row['url'] }}">#{{ $row['id'] }}</a></td>
<td style="border-bottom:1px solid #eeeeee;">{{ $row['is_for'] }}</td>
<td style="border-bottom:1px solid #eeeeee;">{{ $row['name'] !== '' ? $row['name'] : '–' }}</td>
<td style="border-bottom:1px solid #eeeeee;">{{ $row['next_date'] }}</td>
<td style="border-bottom:1px solid #eeeeee;">{{ $row['days'] }}</td>
</tr>
@endforeach
</table>
</td>
</tr>
<tr>
<td style="color:#7B7B7E; font-size: 12px;">Automatische Meldung von <code>abo:check-overdue</code>, täglich 05:30.</td>
</tr>
</table>
</td>
</tr>
</table>
</body>
</html>

View file

@ -0,0 +1,76 @@
<?php
use App\Mail\AboOverdueAlertMail;
use App\Models\UserAbo;
use Carbon\Carbon;
use Illuminate\Foundation\Testing\RefreshDatabase;
use Illuminate\Support\Facades\Mail;
use Tests\TestCase;
uses(TestCase::class, RefreshDatabase::class);
beforeEach(function () {
Carbon::setTestNow(Carbon::parse('2026-09-23 05:30:00'));
Mail::fake();
});
afterEach(fn () => 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();
});