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:
parent
0b8f8abeeb
commit
452d1ac17f
5 changed files with 288 additions and 0 deletions
104
app/Console/Commands/AboCheckOverdue.php
Normal file
104
app/Console/Commands/AboCheckOverdue.php
Normal 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;
|
||||
}
|
||||
}
|
||||
|
|
@ -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')
|
||||
|
|
|
|||
45
app/Mail/AboOverdueAlertMail.php
Normal file
45
app/Mail/AboOverdueAlertMail.php
Normal 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]);
|
||||
}
|
||||
}
|
||||
60
resources/views/emails/abo_overdue_alert.blade.php
Normal file
60
resources/views/emails/abo_overdue_alert.blade.php
Normal 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>
|
||||
76
tests/Feature/AboCheckOverdueTest.php
Normal file
76
tests/Feature/AboCheckOverdueTest.php
Normal 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();
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue