queueViolation()) !== null) { $this->error($violation); return self::FAILURE; } $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 $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 $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. * Liefert [benachrichtigte Kunden, fehlgeschlagene Kunden]. * * Idempotent (Audit N14): Je Kunde eine Transaktion — offene Posten unter * Zeilensperre erneut lesen, den Marker per bedingtem UPDATE beanspruchen * (0 Zeilen = ein anderer Lauf war schneller, nichts einplanen) und erst * DANACH die Mail in derselben Transaktion einplanen. Scheitert das * Einplanen, rollt der Marker mit zurück; der nächste Lauf holt nach. * * Annahme: `QUEUE_CONNECTION=database` ohne eigene `DB_QUEUE_CONNECTION` * (config/queue.php) — der Job-Insert läuft auf derselben Verbindung und * ist damit Teil der Transaktion. handle() sichert das zu (OPT-9, * queueViolation()). Bei einem Wechsel auf Redis o. ä. bräuchte * es stattdessen einen Zwei-Spalten-Claim (`claimed_at` mit Verfall, * `notified_at` nach Zustellung). * * @param Collection $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(); try { $queued = DB::transaction(function () use ($ids, $user, $suppressions): bool { /** @var Collection $open */ $open = $this->pendingNotificationQuery()->whereIn('id', $ids)->lockForUpdate()->get(); if ($open->isEmpty()) { return false; } $claimed = $this->pendingNotificationQuery() ->whereIn('id', $open->pluck('id')->all()) ->update(['grandfathering_notified_at' => now()]); if ($claimed === 0) { return false; } if ($user === null || $suppressions->isSuppressed($user->email)) { // Gesperrte Adresse: ohne Mail als erledigt markiert. return false; } $latestEnd = $open->max(fn (UserPaymentOption $option) => $option->grandfathered_until); Mail::to($user)->queue(new GrandfatheringExpired($user, $latestEnd)); return true; }); } catch (\Throwable $exception) { report($exception); $this->error(sprintf('Benachrichtigung für User #%s konnte nicht eingeplant werden: %s', $userId, $exception->getMessage())); $failed++; continue; } if ($queued) { $notified++; } } return [$notified, $failed]; } /** * Prüft die Annahme aus notifyPending() (E18): Der Mail-Job wird in * derselben DB-Transaktion eingereiht wie der Marker. Das gilt nur für * die Datenbank-Queue auf der Standard-Verbindung — oder für `sync` * (Tests, lokale Läufe), wo der Versand innerhalb der Transaktion läuft * und ein Fehler den Marker mit zurückrollt. Jede andere Queue würde den * Job unabhängig vom Commit einreihen: dann lieber FAILURE (Alarm) als * stille Doppel- oder Nicht-Zustellung. */ private function queueViolation(): ?string { $default = (string) config('queue.default'); if ($default === 'sync') { return null; } if ($default !== 'database') { return sprintf('E18 verletzt: QUEUE_CONNECTION ist „%s“, die Benachrichtigung ist nur mit der Datenbank-Queue idempotent. Nichts geändert.', $default); } $queueConnection = config('queue.connections.database.connection'); if ($queueConnection !== null && $queueConnection !== '' && $queueConnection !== config('database.default')) { return sprintf('E18 verletzt: DB_QUEUE_CONNECTION „%s“ weicht von der Standard-Verbindung „%s“ ab, der Job-Insert läge außerhalb der Transaktion. Nichts geändert.', $queueConnection, config('database.default')); } return null; } /** * Auswahlbedingungen für abgelaufenen Bestandsschutz – bei Auswahl, * Sperre und UPDATE identisch (U11). * * @return Builder */ 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 */ private function pendingNotificationQuery(): Builder { return UserPaymentOption::query() ->whereNotNull('grandfathering_expired_at') ->whereNull('grandfathering_notified_at'); } }