- Checkout-Backend: me.checkout.subscription (Tarif-Abo monatlich/jährlich)
und me.checkout.single-pm (Einzel-PM 19 € netto, pending-Kauf mit
Webhook-Erfüllung); StripeCheckoutService als mockbarer Stripe-Wrapper;
Stripe Tax via Cashier::calculateTaxes() (Netto-Preise, USt-ID-Abfrage)
- Slot-Logik: Kontingent aus dem Tarif (plans.press_release_quota) plus
bezahlte Einmalkäufe; Verbrauch bei Veröffentlichung zuerst aus dem
Plan-Zähler, danach Einlösung des ältesten Einmalkaufs (consumed +
PM-Verknüpfung); Grandfathered = unbegrenzt (Entscheidung 12.06.2026,
Bestandsschutz); Stub-Spalte users.press_release_quota entfernt
- billing:sync-stripe-plans legt zusätzlich das Einzel-PM-Produkt an
(STRIPE_PRICE_SINGLE_PM); Test-Mode-Sync gelaufen
- Buchungs-Seite: Rückmeldung nach Checkout (erfolg/abbruch/Guard-Hinweis)
- Tests: PressReleaseQuotaTest auf Plan-Semantik neu geschrieben,
CheckoutFlowTest (8 Tests), Modal-/API-Tests angepasst; Suite 510 passed
- Doku: Billing-und-Rechnungskreise (Kontingent-Tabelle, Checkout-Routen,
Webhook-Events, Stripe-CLI-Hinweis), PHASE-9-Plan 9E ✅, Checkliste,
STATUS-ABGLEICH, PROGRESS
Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
394 lines
15 KiB
PHP
394 lines
15 KiB
PHP
<?php
|
||
|
||
namespace App\Services\PressRelease;
|
||
|
||
use App\Enums\PressReleaseClassification;
|
||
use App\Enums\PressReleaseStatus;
|
||
use App\Enums\SinglePurchaseStatus;
|
||
use App\Jobs\ClassifyPressRelease;
|
||
use App\Jobs\ScorePressRelease;
|
||
use App\Mail\PressReleasePublished;
|
||
use App\Mail\PressReleaseRejected;
|
||
use App\Models\AdminPreset;
|
||
use App\Models\PressRelease;
|
||
use App\Models\PressReleaseStatusLog;
|
||
use App\Services\Admin\AdminPerformanceCache;
|
||
use Illuminate\Support\Carbon;
|
||
use Illuminate\Support\Facades\Auth;
|
||
use Illuminate\Support\Facades\Cache;
|
||
use Illuminate\Support\Facades\Mail;
|
||
|
||
/**
|
||
* Kapselt Statusübergänge für Pressemitteilungen inkl. Benachrichtigungs-Mails.
|
||
*
|
||
* Jede Methode verändert exklusiv den Status und versendet optional eine Mail
|
||
* an den Autor (User). Die Caller-Schicht (Volt, Command, API) muss nur noch
|
||
* diese Methoden aufrufen – keine Mail-Logik außerhalb dieser Klasse.
|
||
*/
|
||
class PressReleaseService
|
||
{
|
||
public function __construct(private readonly BlacklistService $blacklist) {}
|
||
|
||
public function submitForReview(PressRelease $pressRelease): void
|
||
{
|
||
$this->assertStatus($pressRelease, [PressReleaseStatus::Draft, PressReleaseStatus::Rejected]);
|
||
|
||
$user = $pressRelease->user;
|
||
|
||
// Submit-Gate (Decision-Update §5.1): Einreichen erfordert eine aktive
|
||
// Buchung. Bis zum Tarif-Modul steuert billing.enforce_booking den Stub.
|
||
if ($user && ! $user->hasActiveBooking()) {
|
||
throw new BookingRequiredException;
|
||
}
|
||
|
||
// Slot-Guard: Der Slot wird erst bei Veröffentlichung verbraucht
|
||
// (Decision-Update §3.2) — eingereicht werden darf aber nur, wenn
|
||
// noch ein Slot frei ist, sonst würde eine grüne/gelbe PM ohne
|
||
// verfügbares Kontingent automatisch veröffentlicht. Null bedeutet
|
||
// unbegrenzt (Bestandsschutz bzw. Gate noch nicht scharf).
|
||
$quotaRemaining = $user?->pressReleaseQuotaRemaining();
|
||
|
||
if ($user && $quotaRemaining !== null && $quotaRemaining <= 0) {
|
||
throw new QuotaExceededException;
|
||
}
|
||
|
||
$previous = $pressRelease->status;
|
||
|
||
if ($word = $this->blacklist->findInPressRelease($pressRelease)) {
|
||
$reason = sprintf('Automatische Ablehnung: unzulässiges Wort "%s" gefunden.', $word);
|
||
|
||
$pressRelease->update(['status' => PressReleaseStatus::Rejected->value]);
|
||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, 'blacklist');
|
||
$this->notifyAuthor($pressRelease, 'rejected', $reason);
|
||
|
||
throw new BlacklistViolationException($reason, $word);
|
||
}
|
||
|
||
$pressRelease->update(['status' => PressReleaseStatus::Review->value]);
|
||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Review, null, 'customer');
|
||
|
||
// KI-Klassifikation asynchron anstoßen (Red Flag). Das Routing anhand
|
||
// des Ergebnisses übernimmt der Job über routeByClassification().
|
||
ClassifyPressRelease::dispatch($pressRelease->id)->onQueue('classification');
|
||
|
||
// Content-Score parallel berechnen (Qualität, ohne Statuswirkung).
|
||
ScorePressRelease::dispatch($pressRelease->id)->onQueue('classification');
|
||
}
|
||
|
||
/**
|
||
* Stößt eine erneute KI-Klassifikation an, wenn die PM bereits einmal
|
||
* klassifiziert wurde (Konzept §15.1: „Bei Änderung wird neu klassifiziert").
|
||
*
|
||
* Läuft als Re-Check **ohne Routing**: Bewertung + Audit werden
|
||
* aktualisiert, der Status bleibt unverändert. So führt das bloße
|
||
* Bearbeiten nie zu einer überraschenden automatischen Veröffentlichung
|
||
* oder Ablehnung – die Entscheidung bleibt beim regulären Workflow/Admin.
|
||
*/
|
||
public function reclassifyIfClassified(PressRelease $pressRelease): void
|
||
{
|
||
if ($pressRelease->classification === null) {
|
||
return;
|
||
}
|
||
|
||
ClassifyPressRelease::dispatch($pressRelease->id, route: false)->onQueue('classification');
|
||
}
|
||
|
||
/**
|
||
* Stößt eine erneute Content-Score-Berechnung an, wenn die PM bereits
|
||
* einmal bewertet wurde (Konzept §15.2: „bei jeder Änderung neu berechnet").
|
||
*/
|
||
public function rescoreIfScored(PressRelease $pressRelease): void
|
||
{
|
||
if ($pressRelease->content_score === null) {
|
||
return;
|
||
}
|
||
|
||
ScorePressRelease::dispatch($pressRelease->id)->onQueue('classification');
|
||
}
|
||
|
||
/**
|
||
* Routet eine frisch klassifizierte PM anhand des KI-Ergebnisses
|
||
* (Decision-Update §5.0, Entscheidung 12.06.2026). Wird vom
|
||
* ClassifyPressRelease-Job aufgerufen.
|
||
*
|
||
* - Rot → Ablehnung mit Begründung an den Autor
|
||
* - Gelb/Grün → automatische Veröffentlichung (sofort bzw. zum Termin);
|
||
* Gelb bleibt als interne Markierung erhalten (nicht
|
||
* boostbar, Admin-Signal), löst aber keine manuelle
|
||
* Prüfung aus
|
||
*
|
||
* Greift nur, solange die PM noch im Status `review` steht; manuelle
|
||
* Admin-Eingriffe in der Zwischenzeit haben damit Vorrang.
|
||
*/
|
||
public function routeByClassification(PressRelease $pressRelease, PressReleaseClassification $classification, ?string $reason = null): void
|
||
{
|
||
if ($pressRelease->status !== PressReleaseStatus::Review) {
|
||
return;
|
||
}
|
||
|
||
if ($classification === PressReleaseClassification::Red) {
|
||
$this->reject($pressRelease, $reason ?: 'Automatische Ablehnung durch die KI-Prüfung.', 'ki');
|
||
|
||
return;
|
||
}
|
||
|
||
$this->autoPublishApproved($pressRelease);
|
||
}
|
||
|
||
/**
|
||
* Veröffentlicht eine als Gelb oder Grün klassifizierte PM automatisch.
|
||
*
|
||
* Liegt ein Veröffentlichungstermin in der Zukunft, übernimmt der
|
||
* Scheduler die Publikation zum Termin. Andernfalls wird sofort
|
||
* publiziert – optional mit einem Sicherheitsfenster
|
||
* (scoring.classification.green_delay_minutes).
|
||
*/
|
||
private function autoPublishApproved(PressRelease $pressRelease): void
|
||
{
|
||
if ($pressRelease->scheduled_at && $pressRelease->scheduled_at->isFuture()) {
|
||
return;
|
||
}
|
||
|
||
$delayMinutes = (int) config('scoring.classification.green_delay_minutes', 0);
|
||
$publishedAtOverride = $delayMinutes > 0 ? now()->addMinutes($delayMinutes) : null;
|
||
|
||
$this->publish($pressRelease, 'ki', $publishedAtOverride);
|
||
}
|
||
|
||
public function publish(PressRelease $pressRelease, string $source = 'admin', ?Carbon $publishedAtOverride = null): void
|
||
{
|
||
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
|
||
|
||
$previous = $pressRelease->status;
|
||
|
||
if ($word = $this->blacklist->findInPressRelease($pressRelease)) {
|
||
$reason = sprintf('Automatische Ablehnung: unzulässiges Wort "%s" gefunden.', $word);
|
||
|
||
$pressRelease->update(['status' => PressReleaseStatus::Rejected->value]);
|
||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, 'blacklist');
|
||
$this->notifyAuthor($pressRelease, 'rejected', $reason);
|
||
|
||
throw new BlacklistViolationException($reason, $word);
|
||
}
|
||
|
||
$this->consumePublishSlot($pressRelease);
|
||
|
||
$pressRelease->update([
|
||
'status' => PressReleaseStatus::Published->value,
|
||
'published_at' => $this->resolvePublishedAt($pressRelease, $publishedAtOverride),
|
||
]);
|
||
|
||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Published, null, $source);
|
||
$this->notifyAuthor($pressRelease, 'published');
|
||
}
|
||
|
||
/**
|
||
* Zählt beim ersten Übergang zu „published" einen PM-Slot des Eigentümers
|
||
* (Decision-Update §3.2: Slot-Verbrauch bei Veröffentlichung; abgelehnte
|
||
* PMs kosten nichts). Erneutes Publizieren — etwa nach Archivierung —
|
||
* zählt nicht doppelt; geprüft wird über die Status-Logs. Muss vor dem
|
||
* Schreiben des neuen Status-Logs aufgerufen werden.
|
||
*
|
||
* Verbrauchsreihenfolge: zuerst das Plan-Monatskontingent (Zähler
|
||
* `press_release_quota_used_this_month`), danach der älteste bezahlte
|
||
* Einzel-/Extra-PM-Kauf (wird mit der PM verknüpft und eingelöst).
|
||
* Unbegrenzte User (Bestandsschutz) verbrauchen nichts.
|
||
*/
|
||
private function consumePublishSlot(PressRelease $pressRelease): void
|
||
{
|
||
$alreadyPublishedOnce = PressReleaseStatusLog::query()
|
||
->where('press_release_id', $pressRelease->id)
|
||
->where('to_status', PressReleaseStatus::Published->value)
|
||
->exists();
|
||
|
||
if ($alreadyPublishedOnce) {
|
||
return;
|
||
}
|
||
|
||
$user = $pressRelease->user;
|
||
|
||
if (! $user || $user->hasUnlimitedPressReleaseQuota()) {
|
||
return;
|
||
}
|
||
|
||
$plan = $user->currentPlan();
|
||
|
||
if ($plan && (int) $user->press_release_quota_used_this_month < $plan->press_release_quota) {
|
||
$user->increment('press_release_quota_used_this_month');
|
||
|
||
return;
|
||
}
|
||
|
||
$user->singlePurchases()
|
||
->grantingSubmission()
|
||
->oldest('paid_at')
|
||
->first()
|
||
?->update([
|
||
'status' => SinglePurchaseStatus::Consumed->value,
|
||
'consumed_at' => now(),
|
||
'press_release_id' => $pressRelease->id,
|
||
]);
|
||
}
|
||
|
||
/**
|
||
* Bestimmt das wirksame `published_at` einer PM.
|
||
*
|
||
* Reihenfolge:
|
||
* 1. Bereits gesetztes `published_at` bleibt erhalten (z.B. Re-Publish)
|
||
* 2. `scheduled_at` (geplanter Veröffentlichungstermin) hat Vorrang vor "jetzt"
|
||
* 3. `embargo_at` (Sperrfrist) verschiebt zusätzlich nach hinten — egal ob
|
||
* Scheduled vorhanden ist oder nicht
|
||
* 4. Fallback: now()
|
||
*
|
||
* Damit wirken sowohl Scheduling als auch Embargo automatisch über den
|
||
* vorhandenen Sichtbarkeits-Filter `where(published_at <= now())` im
|
||
* öffentlichen Listing.
|
||
*
|
||
* `$override` setzt einen abweichenden Sofort-Zeitpunkt (z.B. das
|
||
* Grün-Sicherheitsfenster) und wirkt nur, wenn kein `scheduled_at` gesetzt
|
||
* ist – ein geplanter Termin hat stets Vorrang.
|
||
*/
|
||
private function resolvePublishedAt(PressRelease $pressRelease, ?Carbon $override = null): Carbon
|
||
{
|
||
if ($pressRelease->published_at) {
|
||
return $pressRelease->published_at;
|
||
}
|
||
|
||
$base = $pressRelease->scheduled_at ?: ($override ?? now());
|
||
|
||
if ($pressRelease->embargo_at && $pressRelease->embargo_at->greaterThan($base)) {
|
||
return $pressRelease->embargo_at;
|
||
}
|
||
|
||
return $base;
|
||
}
|
||
|
||
public function reject(PressRelease $pressRelease, ?string $reason = null, string $source = 'admin'): void
|
||
{
|
||
$this->assertStatus($pressRelease, [PressReleaseStatus::Review]);
|
||
|
||
$previous = $pressRelease->status;
|
||
|
||
$pressRelease->update(['status' => PressReleaseStatus::Rejected->value]);
|
||
|
||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Rejected, $reason, $source);
|
||
$this->notifyAuthor($pressRelease, 'rejected', $reason);
|
||
}
|
||
|
||
public function backToDraft(PressRelease $pressRelease): void
|
||
{
|
||
$this->assertStatus($pressRelease, [PressReleaseStatus::Review, PressReleaseStatus::Rejected]);
|
||
|
||
$previous = $pressRelease->status;
|
||
|
||
$pressRelease->update(['status' => PressReleaseStatus::Draft->value]);
|
||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Draft, null, 'admin');
|
||
}
|
||
|
||
public function archive(PressRelease $pressRelease): void
|
||
{
|
||
$this->assertStatus($pressRelease, [PressReleaseStatus::Published]);
|
||
|
||
$previous = $pressRelease->status;
|
||
|
||
$pressRelease->update(['status' => PressReleaseStatus::Archived->value]);
|
||
$this->logStatusChange($pressRelease, $previous, PressReleaseStatus::Archived, null, 'admin');
|
||
}
|
||
|
||
public function changeStatusFromAdmin(PressRelease $pressRelease, PressReleaseStatus $status, ?string $reason = null): void
|
||
{
|
||
$previous = $pressRelease->status;
|
||
|
||
if ($status === PressReleaseStatus::Published && $previous !== PressReleaseStatus::Published) {
|
||
$this->consumePublishSlot($pressRelease);
|
||
}
|
||
|
||
$pressRelease->update([
|
||
'status' => $status->value,
|
||
'published_at' => $status === PressReleaseStatus::Published
|
||
? ($pressRelease->published_at ?? now())
|
||
: $pressRelease->published_at,
|
||
]);
|
||
|
||
if ($previous !== $status) {
|
||
$this->logStatusChange($pressRelease, $previous, $status, $reason, 'admin');
|
||
}
|
||
}
|
||
|
||
public function deleteFromAdmin(PressRelease $pressRelease): void
|
||
{
|
||
if ($pressRelease->status === PressReleaseStatus::Published) {
|
||
$pressRelease->update([
|
||
'status' => PressReleaseStatus::Archived->value,
|
||
'text' => AdminPreset::activeValue(
|
||
AdminPreset::PRESS_RELEASE_DELETED_PUBLISHED_TEXT,
|
||
"Diese Pressemitteilung wurde entfernt.\n\nDer Inhalt ist nicht mehr verfuegbar."
|
||
),
|
||
'keywords' => null,
|
||
'backlink_url' => null,
|
||
'no_export' => true,
|
||
]);
|
||
|
||
return;
|
||
}
|
||
|
||
$pressRelease->delete();
|
||
}
|
||
|
||
/**
|
||
* @param PressReleaseStatus[] $allowed
|
||
*/
|
||
private function assertStatus(PressRelease $pressRelease, array $allowed): void
|
||
{
|
||
if (! in_array($pressRelease->status, $allowed, true)) {
|
||
$allowedValues = implode(', ', array_map(fn ($s) => $s->value, $allowed));
|
||
$currentStatus = $pressRelease->status instanceof PressReleaseStatus
|
||
? $pressRelease->status->value
|
||
: (string) $pressRelease->status;
|
||
|
||
throw new \LogicException(
|
||
"Statusübergang nicht erlaubt. Aktueller Status: {$currentStatus}, erwartet: {$allowedValues}"
|
||
);
|
||
}
|
||
}
|
||
|
||
private function logStatusChange(
|
||
PressRelease $pressRelease,
|
||
?PressReleaseStatus $from,
|
||
PressReleaseStatus $to,
|
||
?string $reason,
|
||
string $source,
|
||
): void {
|
||
PressReleaseStatusLog::query()->create([
|
||
'press_release_id' => $pressRelease->id,
|
||
'changed_by_user_id' => Auth::id(),
|
||
'from_status' => $from?->value,
|
||
'to_status' => $to->value,
|
||
'reason' => $reason,
|
||
'source' => $source,
|
||
'created_at' => now(),
|
||
]);
|
||
|
||
Cache::forget(AdminPerformanceCache::PressReleaseStats);
|
||
Cache::forget(AdminPerformanceCache::PressReleaseReviewCount);
|
||
}
|
||
|
||
private function notifyAuthor(PressRelease $pressRelease, string $event, ?string $reason = null): void
|
||
{
|
||
$user = $pressRelease->user;
|
||
|
||
if (! $user || ! $user->email) {
|
||
return;
|
||
}
|
||
|
||
$mailable = match ($event) {
|
||
'published' => new PressReleasePublished($user, $pressRelease),
|
||
'rejected' => new PressReleaseRejected($user, $pressRelease, $reason),
|
||
default => null,
|
||
};
|
||
|
||
if ($mailable) {
|
||
Mail::to($user->email)->queue($mailable);
|
||
}
|
||
}
|
||
}
|