presseportale/app/Console/Commands/ExpireGrandfathering.php
Kevin Adametz 2a2215b3c3 audit: Nacharbeit Umsetzungsaudit 25.09.2026 — Bildfreigabe, Beweisarchiv, KI-Atomarität, Berichte (Block 8, N01–N05, N07, N08, N10–N21, 5.1, 5.2)
Bilder (N01, N05, N07, N08): gemeinsamer Freigabezustand
PressReleaseImage::isPubliclyDeliverable() für Seiten, OG und Medienroute
(Bestand bleibt sichtbar, v2 nur mit Bestätigung, Admin-Upload bis zum
Kundenklick nicht öffentlich); eigene Ability confirmImageRights nur für den
Kontoinhaber, ohne Super-Admin-Kurzschluss, Identität und ausstehende
Bestätigung auch im Service; Status-Regel unter der PM-Sperre; Cache-Flush
nach dem Commit außerhalb der Dateibereinigung; Snapshot nach Bestätigung an
veröffentlichter PM; Bestätigungstexte an die gezeigte Kennung gebunden.

Beweisarchiv (N02, N10, N11, N12, N13, 5.1): Tombstone unter Zeilensperre mit
erneuter Hold-/Fristprüfung; Dateioperationen ausgewertet, Commands mit
FAILURE; record() über .tmp + move; Marker archive_pending_at mit
archive:retry-pending und archive:sweep-orphans; Anonymisierung entfernt Bilder
über den Remover mit Antragsbezug; Draft-Purge räumt Medien nie
veröffentlichter abgelehnter Einreichungen; App-Kopien fallen mit dem
Archiv-Tombstone; label_corrected nur für veröffentlichte Bilder;
archive_entries.legal_request_id; FileDeletion-Helfer; Systemstatus-Checks.

KI und Hintergrund (N03, N04, N14, N20, N21): Ergebnis-UPDATE und Audit in
einer Transaktion; erwarteter Hash bis in reject()/holdForManualReview();
Ablaufmail per beanspruchtem Marker in einer Transaktion; strikte
Schemaprüfung der Vision-Antwort; Fortschrittsmarke ungecacht.

Berichte und Canonical (N15–N19, 5.2): lückenlose Alterskohorten;
Feed-Identität mit Portal und Sprache; Sitemap-Rolle in der Kundenansicht;
Indexverteilung auf einer Grundgesamtheit; Kopien nach Wegfall der
Erstquelle neu bewertet; Kategorie-Admin leert Seiten-Caches; Dry-Run
zählt alle Tabellen.

Gesamtsuite: 2.122 Tests grün, einer übersprungen; Larastan und Pint sauber.
Zwei Migrationen (nullable Spalten, INSTANT). N09 offen.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-25 14:26:49 +00:00

243 lines
9.5 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.
* 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. Bei einem Wechsel auf Redis o. ä. bräuchte
* es stattdessen einen Zwei-Spalten-Claim (`claimed_at` mit Verfall,
* `notified_at` nach Zustellung).
*
* @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();
try {
$queued = DB::transaction(function () use ($ids, $user, $suppressions): bool {
/** @var Collection<int, UserPaymentOption> $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];
}
/**
* 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');
}
}