presseportale/app/Console/Commands/ExpireGrandfathering.php
Kevin Adametz eb95b3b830 billing: Vertragsablauf bedingt beenden, Kundenmail nachholbar (Block 7, U11/U12)
- Beenden je Kunde in einer Transaktion mit erneuter Prüfung aller
  Auswahlbedingungen unter Zeilensperre; Ereignis nur für getroffene Zeilen
- Marker grandfathering_expired_at/-notified_at; Mail in zweiter Phase,
  bei Fehler beim Einplanen FAILURE und Nachholen im nächsten Lauf

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-18 12:03:04 +00:00

212 lines
8.1 KiB
PHP
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

<?php
namespace App\Console\Commands;
use App\Enums\ActivityType;
use App\Enums\UserPaymentOptionStatus;
use App\Mail\GrandfatheringExpired;
use App\Models\User;
use App\Models\UserPaymentOption;
use App\Services\ActivityLog;
use App\Services\Mail\EmailSuppressionService;
use Illuminate\Console\Command;
use Illuminate\Database\Eloquent\Builder;
use Illuminate\Database\Eloquent\Collection;
use Illuminate\Support\Facades\DB;
use Illuminate\Support\Facades\Mail;
/**
* Beendet Bestandsschutz-Posten, deren explizites Legacy-Vertragsende
* (`grandfathered_until`, aus valid_until_date importiert) überschritten ist
* (Audit F06, Plan 5.2).
*
* Bisher endete ein solcher Vertrag nur im Zugriff (scopeGrandfatheredNow),
* nicht im Datensatz: Status blieb `grandfathered`, der MAN-Rechnungslauf
* hätte weiter abgerechnet, und niemand sagte dem Kunden Bescheid. Jetzt
* läuft der Befehl täglich vor dem Rechnungslauf in zwei Phasen:
*
* Phase 1 (Audit U11): je Kunde in einer Transaktion. Die Kandidaten werden
* unter Zeilensperre mit allen Auswahlbedingungen erneut gelesen und nur mit
* denselben Bedingungen auf `cancelled` gesetzt (`cancelled_at` und
* `grandfathering_expired_at` im selben Schreibzugriff). Eine zwischenzeitliche
* Verlängerung bleibt so erhalten; ein überlappender Lauf trifft null Zeilen
* und schreibt weder Ereignis noch Benachrichtigungsauftrag. Das
* ActivityLog-Ereignis liegt im selben Commit und zählt nur die tatsächlich
* getroffenen Posten.
*
* Phase 2 (Audit U12): alle Posten mit `grandfathering_expired_at` ohne
* `grandfathering_notified_at` werden je Kunde mit genau einer Mail
* nachgeholt und erst danach als benachrichtigt markiert. Scheitert das
* Einplanen, bleibt der Marker leer, der Befehl meldet FAILURE (Scheduler-
* Alarm) und der nächste Lauf holt die Mail nach – ohne den Vertrag erneut
* anzufassen. Gesperrte Adressen werden ohne Mail als erledigt markiert.
*/
class ExpireGrandfathering extends Command
{
protected $signature = 'billing:expire-grandfathering
{--dry-run : Nur anzeigen, welche Posten beendet und welche Kunden benachrichtigt würden}';
protected $description = 'Beendet Bestandsschutz-Posten mit überschrittenem Legacy-Vertragsende und informiert den Kunden.';
public function handle(ActivityLog $activityLog, EmailSuppressionService $suppressions): int
{
$candidates = $this->expiredQuery()->with('user')->get();
$pending = $this->pendingNotificationQuery()->with('user')->get();
if ($candidates->isEmpty() && $pending->isEmpty()) {
$this->info('Kein Bestandsschutz mit überschrittenem Vertragsende, keine offene Benachrichtigung.');
return self::SUCCESS;
}
if ($this->option('dry-run')) {
foreach ($candidates as $option) {
$this->line(sprintf('[dry-run] Posten #%d (User #%s) endete am %s', $option->id, $option->user_id, $option->grandfathered_until?->toDateString()));
}
foreach ($pending as $option) {
$this->line(sprintf('[dry-run] Posten #%d (User #%s): Benachrichtigung seit %s offen', $option->id, $option->user_id, $option->grandfathering_expired_at?->toDateTimeString()));
}
return self::SUCCESS;
}
$ended = $this->endExpired($candidates, $activityLog);
[$notified, $failed] = $this->notifyPending($this->pendingNotificationQuery()->with('user')->get(), $suppressions);
$this->info(sprintf('%d Bestandsschutz-Posten beendet, %d Kunden benachrichtigt.', $ended, $notified));
if ($failed > 0) {
$this->error(sprintf('%d Benachrichtigung(en) konnten nicht eingeplant werden – der nächste Lauf holt sie nach.', $failed));
return self::FAILURE;
}
return self::SUCCESS;
}
/**
* Phase 1: bedingtes Beenden je Kunde. Liefert die Zahl der tatsächlich
* von diesem Lauf beendeten Posten.
*
* @param Collection<int, UserPaymentOption> $candidates
*/
private function endExpired(Collection $candidates, ActivityLog $activityLog): int
{
$ended = 0;
foreach ($candidates->groupBy('user_id') as $options) {
/** @var User|null $user */
$user = $options->first()?->user;
$ids = $options->pluck('id')->all();
$ended += DB::transaction(function () use ($ids, $user, $activityLog): int {
/** @var Collection<int, UserPaymentOption> $current */
$current = $this->expiredQuery()->whereIn('id', $ids)->lockForUpdate()->get();
if ($current->isEmpty()) {
return 0;
}
$now = now();
$affected = $this->expiredQuery()
->whereIn('id', $current->pluck('id')->all())
->update([
'status' => UserPaymentOptionStatus::Cancelled->value,
'cancelled_at' => $now,
'grandfathering_expired_at' => $now,
]);
if ($affected === 0) {
return 0;
}
$latestEnd = $current->max(fn (UserPaymentOption $option) => $option->grandfathered_until);
$activityLog->record(
ActivityType::SubscriptionChange,
sprintf('Bestandsschutz zum Vertragsende %s beendet (%d Posten).', $latestEnd?->format('d.m.Y'), $affected),
$user,
null,
['action' => 'expire_grandfathering', 'posts' => $affected, 'until' => $latestEnd?->toDateString()],
'system',
);
return $affected;
});
}
return $ended;
}
/**
* Phase 2: je Kunde genau eine Mail für alle offenen Posten, danach
* Marker setzen. Liefert [benachrichtigte Kunden, fehlgeschlagene Kunden].
*
* @param Collection<int, UserPaymentOption> $pending
* @return array{0: int, 1: int}
*/
private function notifyPending(Collection $pending, EmailSuppressionService $suppressions): array
{
$notified = 0;
$failed = 0;
foreach ($pending->groupBy('user_id') as $userId => $options) {
/** @var User|null $user */
$user = $options->first()?->user;
$ids = $options->pluck('id')->all();
if ($user !== null && ! $suppressions->isSuppressed($user->email)) {
$latestEnd = $options->max(fn (UserPaymentOption $option) => $option->grandfathered_until);
try {
Mail::to($user)->queue(new GrandfatheringExpired($user, $latestEnd));
} catch (\Throwable $exception) {
report($exception);
$this->error(sprintf('Benachrichtigung für User #%s konnte nicht eingeplant werden: %s', $userId, $exception->getMessage()));
$failed++;
continue;
}
$notified++;
}
UserPaymentOption::query()
->whereIn('id', $ids)
->whereNull('grandfathering_notified_at')
->update(['grandfathering_notified_at' => now()]);
}
return [$notified, $failed];
}
/**
* Auswahlbedingungen für abgelaufenen Bestandsschutz – bei Auswahl,
* Sperre und UPDATE identisch (U11).
*
* @return Builder<UserPaymentOption>
*/
private function expiredQuery(): Builder
{
return UserPaymentOption::query()
->where('status', UserPaymentOptionStatus::Grandfathered->value)
->whereNull('cancelled_at')
->whereNotNull('grandfathered_until')
->whereDate('grandfathered_until', '<', today());
}
/**
* Beendete Posten, deren Kunde noch nicht informiert wurde (U12).
*
* @return Builder<UserPaymentOption>
*/
private function pendingNotificationQuery(): Builder
{
return UserPaymentOption::query()
->whereNotNull('grandfathering_expired_at')
->whereNull('grandfathering_notified_at');
}
}