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>
This commit is contained in:
Kevin Adametz 2026-09-18 12:03:04 +00:00
parent 05cf53e17d
commit eb95b3b830
4 changed files with 363 additions and 46 deletions

View file

@ -10,6 +10,8 @@ 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;
@ -21,79 +23,190 @@ use Illuminate\Support\Facades\Mail;
* 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, setzt die Posten auf
* `cancelled` und schickt je Kunde genau eine Mail. Idempotent: bereits
* gekündigte Posten werden nicht mehr angefasst.
* 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 würden}';
{--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
{
$expired = UserPaymentOption::query()
->where('status', UserPaymentOptionStatus::Grandfathered->value)
->whereNull('cancelled_at')
->whereNotNull('grandfathered_until')
->whereDate('grandfathered_until', '<', today())
->with('user')
->get();
$candidates = $this->expiredQuery()->with('user')->get();
$pending = $this->pendingNotificationQuery()->with('user')->get();
if ($expired->isEmpty()) {
$this->info('Kein Bestandsschutz mit überschrittenem Vertragsende.');
if ($candidates->isEmpty() && $pending->isEmpty()) {
$this->info('Kein Bestandsschutz mit überschrittenem Vertragsende, keine offene Benachrichtigung.');
return self::SUCCESS;
}
if ($this->option('dry-run')) {
foreach ($expired as $option) {
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 = 0;
$ended = $this->endExpired($candidates, $activityLog);
foreach ($expired->groupBy('user_id') as $userId => $options) {
/** @var User|null $user */
$user = $options->first()?->user;
[$notified, $failed] = $this->notifyPending($this->pendingNotificationQuery()->with('user')->get(), $suppressions);
DB::transaction(function () use ($options, &$ended): void {
$ended += UserPaymentOption::query()
->whereIn('id', $options->pluck('id')->all())
->whereNull('cancelled_at')
->update([
'status' => UserPaymentOptionStatus::Cancelled->value,
'cancelled_at' => now(),
]);
});
$this->info(sprintf('%d Bestandsschutz-Posten beendet, %d Kunden benachrichtigt.', $ended, $notified));
if ($user === null) {
continue;
}
if ($failed > 0) {
$this->error(sprintf('%d Benachrichtigung(en) konnten nicht eingeplant werden – der nächste Lauf holt sie nach.', $failed));
$latestEnd = $options->max(fn (UserPaymentOption $option) => $option->grandfathered_until);
$activityLog->record(
ActivityType::SubscriptionChange,
sprintf('Bestandsschutz zum Vertragsende %s beendet (%d Posten).', $latestEnd?->format('d.m.Y'), $options->count()),
$user,
null,
['action' => 'expire_grandfathering', 'posts' => $options->count(), 'until' => $latestEnd?->toDateString()],
'system',
);
if (! $suppressions->isSuppressed($user->email)) {
Mail::to($user)->queue(new GrandfatheringExpired($user, $latestEnd));
}
return self::FAILURE;
}
$this->info(sprintf('%d Bestandsschutz-Posten beendet.', $ended));
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');
}
}

View file

@ -26,6 +26,8 @@ class UserPaymentOption extends Model
'billing_hold_at',
'stripe_subscription_id',
'cancelled_at',
'grandfathering_expired_at',
'grandfathering_notified_at',
];
protected function casts(): array
@ -38,6 +40,8 @@ class UserPaymentOption extends Model
'current_period_end' => 'date',
'billing_hold_at' => 'datetime',
'cancelled_at' => 'datetime',
'grandfathering_expired_at' => 'datetime',
'grandfathering_notified_at' => 'datetime',
];
}